Gamification Engine — Feature & Testing Guide
Overview
The gamification engine is the cross-cutting system that awards XP, advances levels, and fires level-up notifications across every significant user action. It is implemented as a single service class — GamificationService — that keeps the XP logic out of models and interactions while remaining easy to call from anywhere.
XP flows into the engine from three sources: habit check-ins (via CheckInHabit), goal completions (via UpdateGoal), and daily activity streaks (via User#update_streak!). Each call to award_xp immediately evaluates whether the user has crossed one or more level thresholds, and enqueues a CreateLevelUpNotificationJob for each level gained. The entire mutation is wrapped in a row-level database lock to prevent race conditions under concurrent requests.
Daily activity streaks now reflect real user engagement across multiple activity types. A streak is the longest consecutive run of days on which the user performed any of: habit check-in (HabitCompletion), goal event (GoalEvent), or mood log entry (MoodLog). Streak boundaries are evaluated in the user's local timezone (not UTC or server time). This unified activity metric replaces the previous sign-in-only model — see the Streak Calculation section below for details and the support staff migration note.
Badges and achievements are a separate but related system. UserAction records capture milestone completions and feed the achievement display, but badge unlocks do not flow through GamificationService — they are created directly by interaction code. See docs/features/achievements.md for that layer. Focus Mode users also see a dedicated MilestoneCard surface that highlights in-session badge and streak unlocks before the session closes (see docs/features/focus-mode-milestone-card.md). This guide focuses on the XP, level, and notification pipeline.
GamificationService API
All three methods are class methods. The service holds no state.
GamificationService.award_xp(user, amount, reason = nil)
Awards XP to a user and checks for level progression.
| Parameter | Type | Required | Description |
|---|---|---|---|
user | User | Yes | The user receiving XP. Guards against nil — returns immediately if user is nil. |
amount | Integer | Yes | XP to add. No minimum enforced in the service. |
reason | String | No | Human-readable label written to the log line. |
Returns nil. Side effects: increments user.xp, may increment user.level, calls user.save!, logs an info line, enqueues CreateLevelUpNotificationJob for each level gained.
The entire read-modify-write sequence runs inside user.with_lock, which acquires a SELECT FOR UPDATE row lock on the users table row before reading XP. This prevents double-counting under concurrent habit check-ins or goal completions.
GamificationService.check_for_level_up(user)
Iteratively compares the user's current XP against the threshold for the next level and increments user.level until no further threshold is crossed. Called internally by award_xp — callers should not invoke this directly.
The loop fires CreateLevelUpNotificationJob once per level gained, passing user.id and the new level integer. In development and test environments, the job runs synchronously (perform_now). In production it is enqueued (perform_later).
This method mutates user.level in memory but does not save. The caller (award_xp) saves via user.save! after the loop completes.
GamificationService.calculate_threshold(level)
Returns the total XP required to reach the given level. Used by check_for_level_up, User#next_level_threshold, and User#progress_to_next_level.
| Level | XP Threshold |
|---|---|
| 1 | 0 |
| 2 | 500 |
| 3 | 1,000 |
| 4 | 2,000 |
| 5 | 3,500 |
| 6 | 5,500 |
| 7 | 8,000 |
| 8 | 11,000 |
| 9 | 15,000 |
| 10 | 20,000 |
| 11+ | 20,000 + (level − 10) × 5,000 |
Beyond level 10 the curve becomes linear at +5,000 XP per level with no hard cap.
XP Awards
| Triggering Action | Amount | Source | Reason String |
|---|---|---|---|
| Habit check-in | 25 XP | Gamification::Subscribers.handle_habit_checked_in (triggered via goal_tracking.habit_checked_in DomainEvent published by CheckInHabit) | 'Habit Check-in' |
| Goal completion | 500 XP | UpdateGoal interaction | 'Goal Completion' |
| Daily activity streak extended | 50 XP | User#update_streak! | 'Daily Streak' |
| Accountability partner co-op check-in | 25 XP | Gamification::AwardPartnerBonus | 'Partner Bonus' |
The streak award fires only when new_active_streak > current_streak, so it is awarded at most once per calendar day (in the user's local timezone). No XP is awarded for goal event creation (AddGoalEvent) — that interaction does not call GamificationService.
Partner Bonus
When two accountability partners both show activity on the same calendar day, each partner receives +25 XP labeled 'Partner Bonus'. This co-op bonus reinforces the daily partnership loop — both partners must show up for either to earn it. Activity includes habit check-ins, goal events, or mood logs.
How it works:
- A user performs any activity (habit check-in via
CheckInHabit, goal event, or mood log). - After recording the activity, the user action code calls
Gamification::AwardPartnerBonus.call(user:, completed_date:). - The interaction finds all active accountability partnerships for the user via
UserAlly.with_active_partnership(bidirectional — works regardless of whether the user isuser_idorally_idon theUserAllyrecord). - For each partnership, it checks whether the partner has any activity (habit completion, goal event, or mood log) for the same calendar date in the user's local timezone.
- If the partner has been active, both the user and the partner receive +25 XP via
GamificationService.award_xp.
Idempotency: Each UserAlly record tracks last_partner_bonus_date. The bonus is awarded at most once per partnership per calendar day (in the user's local timezone). The check runs inside a with_lock block on the UserAlly record to prevent race conditions when both partners are active simultaneously.
Streak tracking: On each co-op day, mutual_streak_count on the UserAlly record is incremented (or reset to 1 if the previous bonus was not yesterday). longest_mutual_streak tracks the all-time best. Streak reset logic for missed days is handled separately (see OBJ-21).
Inputs:
| Parameter | Type | Required | Description |
|---|---|---|---|
user | User | Yes | The user who just performed activity |
completed_date | Date | No | The date of the activity (in the user's local timezone). Defaults to Date.current (evaluated in user's timezone). |
Level Thresholds
See the table in the calculate_threshold section above. The threshold array is hardcoded in the service as a case/when statement:
case level
when 1 then 0
when 2 then 500
when 3 then 1000
when 4 then 2000
when 5 then 3500
when 6 then 5500
when 7 then 8000
when 8 then 11_000
when 9 then 15_000
when 10 then 20_000
else
20_000 + ((level - 10) * 5000)
endTo change the curve, update this method and re-run bundle exec rspec spec/services/gamification_service_spec.rb to verify existing threshold assertions still pass.
Rank Titles
This is the XP rank ladder — server-authoritative, computed by Achievements::ComputeUserRankService from user.xp (it derives an effective level from XP rather than trusting the stored user.level column, so the displayed rank always matches the XP bar even if level has drifted) and mirrored client-side in ionic_frontend/src/constants/gamification.ts's RANK_LADDER constant (kept in sync by a cross-language test asserting both lists match, in order). It is distinct from the streak rank ladder (ionic_frontend/src/composables/streakRanks.ts), which uses the unrelated masonry vocabulary below. The two ladders 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).
| Level | Rank Title |
|---|---|
| 1 | Open Road |
| 2 | Bearings |
| 3 | Full Stride |
| 4 | Second Wind |
| 5 | Long Haul |
| 6 | Ridgeline |
| 7 | Frontier |
| 8 | Wayfinder |
| 9 | Far Country |
| 10+ | Horizon |
Concurrency Safety
award_xp wraps the XP increment in user.with_lock, which issues a PostgreSQL SELECT ... FOR UPDATE on the users row. This means:
- Concurrent requests for the same user (e.g., two simultaneous habit check-ins) queue behind the lock.
- The second request reads the XP value already committed by the first, so no XP is lost or double-counted.
- Deadlocks are possible if two transactions lock the same user in different orders alongside other locked rows. Current code only locks the users row, so this is low risk.
The lock is held for the duration of the with_lock block, which includes check_for_level_up and user.save!. Keep the block short — do not make network calls or fire jobs inside it.
Multi-Level-Up Handling
If a single award_xp call pushes a user across more than one threshold at once (e.g., awarding 2,000 XP to a Level 1 user with 0 XP jumps them directly to Level 4), check_for_level_up iterates the loop until no further threshold is crossed:
- Checks threshold for Level 2 (500 XP) — passes → increments to Level 2, enqueues notification for Level 2
- Checks threshold for Level 3 (1,000 XP) — passes → increments to Level 3, enqueues notification for Level 3
- Checks threshold for Level 4 (2,000 XP) — passes → increments to Level 4, enqueues notification for Level 4
- Checks threshold for Level 5 (3,500 XP) — fails → loop exits
The spec at spec/services/gamification_service_spec.rb covers this explicitly:
context 'when xp crosses multiple level thresholds at once' do
before { user.xp = 2000 }
it 'levels up multiple times' do
described_class.check_for_level_up(user)
expect(user.level).to eq(4)
end
it 'enqueues a notification for each level gained' do
expect(CreateLevelUpNotificationJob).to receive(:perform_now).with(user.id, 2).ordered
expect(CreateLevelUpNotificationJob).to receive(:perform_now).with(user.id, 3).ordered
expect(CreateLevelUpNotificationJob).to receive(:perform_now).with(user.id, 4).ordered
described_class.check_for_level_up(user)
end
endIntegration Points
| Call Site | File | XP Amount | Trigger |
|---|---|---|---|
Gamification::Subscribers.handle_habit_checked_in | app/interactions/gamification/subscribers.rb:53 | 25 | goal_tracking.habit_checked_in DomainEvent (published by CheckInHabit; XP is not awarded directly by CheckInHabit) |
UpdateGoal#handle_completed_goals | app/interactions/goal_tracking/update_goal.rb:288 | 500 | Goal marked completed (guards with saved_change_to_completed?) |
User#update_streak! | app/models/user.rb:164 | 50 | Daily sign-in streak extended |
Gamification::AwardPartnerBonus#call | app/interactions/gamification/award_partner_bonus.rb | 25 | Both accountability partners checked in today |
User#next_level_threshold | app/models/user.rb:192 | N/A | Calls calculate_threshold(level + 1) for display |
User#progress_to_next_level | app/models/user.rb:196 | N/A | Calls calculate_threshold(level) for progress percentage |
The 500 XP goal completion award is guarded by goal.saved_change_to_completed? — it fires only on the transition from incomplete to complete, not on repeated saves.
AddGoalEvent does not call GamificationService. Goal event creation does not award XP directly.
Streak Calculation
User streaks now measure consecutive days of any activity, evaluated in the user's local timezone.
Activity Union
A day counts toward the user's streak if the user performed any of these actions:
- Checked in on a habit (
HabitCompletionrecord created) - Created or updated a goal event (
GoalEventrecord created) - Logged a mood entry (
MoodLogrecord created)
The streak is the longest consecutive run of such days, re-evaluated daily at midnight in the user's local timezone.
Timezone Handling
- The user's timezone is stored in
User#timezone(IANA string, e.g.,'America/Chicago'). - Populated from the frontend via
Intl.DateTimeFormat().resolvedOptions().timeZoneon each sign-in. - Falls back to UTC (with a Sentry breadcrumb) if the IANA string is unrecognized by Rails.
- The
User#timezone_or_defaulthelper resolves the timezone safely. User#update_streak!evaluates all activity dates in the user's timezone viaTime.use_zone(tz).
Support Staff Note (Migration)
Prior streak records (User#current_streak, Goal#habit_streak) were calculated using server UTC boundaries and are not re-attributed to the user's local timezone retroactively. This release changes the calculation forward-only — streaks from this point onward respect the user's local midnight. Some users may see their reported streak number change as historical data stabilizes under the new model. This is expected and correct.
Deleting a Goal Does Not Erase Its Past Streak Credit
User#streak_activity_dates (app/models/user.rb:148) queries habit_dates, event_dates, and first_goal_dates through goals.with_deleted, so a soft-deleted goal's own deleted_at no longer excludes its past activity from the streak. Only the activity record's own deleted_at still matters — a GoalEvent or HabitCompletion that was itself deleted no longer counts, regardless of its parent goal's state.
This matters because goal.delete (app/interactions/goal_tracking/update_goal.rb:125, fired when a user deletes a goal) soft-deletes the goal but does not cascade to its events or habit completions — those rows stay live. Before this fix, Goal's default_scope { where(deleted_at: nil) } meant that live-but-orphaned activity silently dropped out of the streak and the "Last 30 Days" heatmap (StreakDetailsModal.vue, powered by the signInDates GraphQL field) the moment its parent goal was deleted — a user could delete an old, abandoned goal and watch their streak or heatmap shrink with no explanation. That gap is fixed: deleting a goal only removes it from goal lists, not from the user's activity history.
The stored counters (current_streak, longest_streak) heal lazily — they recompute on the user's next update_streak! call (i.e. their next activity), not immediately on deploy. The heatmap (signInDates/graceDays, computed live from streak_activity_dates) reflects the corrected data immediately.
Also fixed, in a follow-up (OBJ-1727): the /analytics Activity heatmap (PersonalAnalytics::CalculateActivityHeatmap) computes its own tallies independently rather than reusing User#streak_activity_dates, and used to have the same goal-soft-deletion undercount described above — it's now been brought into parity: a soft-deleted goal's still-live HabitCompletion/GoalEvent rows are queried through goals.with_deleted and counted there too, while a GoalEvent's own deleted_at is still respected. See Personal Analytics — Activity heatmap.
Absent User Data Never Renders as a Zero Streak
A production probe on the same OBJ-1711 reporting account (see above) turned up a second, unrelated defect: the account's real streak (current_streak=3, credited entirely from GoalEvent/MoodLog activity — it has zero habits) was showing as a confident 0-day streak with a fully blank 30-day heatmap on the Dashboard, at a moment when the backend had already recorded the 3. The backend calculation was correct the whole time; the frontend was rendering a state it never actually observed.
Dashboard.vue's streak card and StreakDetailsModal.vue used to derive their display straight from user.value?.streak || 0, user.value?.signInDates || [], etc., where user fell back to {} whenever USER_QUERY returned no data — whether because the query errored, or because it never fired at all (gated on enabled: userSynced.value && !!currentUserId.value, which stays false if currentUserId is null or stale). Both paths produced the exact same render as a genuine zero streak, with no loading spinner and no error message to tell the two apart — and the dashboard's own hasError guard only tripped when both USER_QUERY and GOALS_QUERY failed, so a lone USER_QUERY problem (with goal cards rendering fine from the healthy GOALS_QUERY) was completely silent.
The fix adds a streakQueryState/streakState computed (Dashboard.vue) that's independent of hasError — so it doesn't disturb the existing loading-gates-hasError guard that prevents a mid-flight errorPolicy: 'all' error from cycling GoalsSection's fade animation. It resolves to ready the instant real user data exists (a background refetch never flickers the pill), unavailable when the query has errored or never fired, and loading otherwise — a loading state stuck for more than 8 seconds degrades to unavailable rather than hanging indefinitely. The streak pill and StreakDetailsModal (new state prop, defaulting to 'ready') now render a skeleton or an honest "Couldn't load your streak" message with a "Try again" retry instead of a fabricated zero. A blank heatmap and a 0 streak are reserved for a confirmed zero — never for "we don't know yet."
One silent auto-retry fires the first time the state becomes unavailable, calling refetchUser(). For the query-never-fired case specifically, that retry used to be a no-op — the query was never enabled in the first place, so refetching it didn't re-establish currentUserId. Fixed in OBJ-1730: retryStreak() (Dashboard.vue:937-942) now calls useClerkSync.resyncClerkUser() first when currentUserId is null, then refetchUser() — see clerk-native-api-gotchas.md — Two Clerk-sync entry points for why the fix needed a router-free composable entry point rather than importing the router module directly.
CreateLevelUpNotificationJob
module Gamification
class CreateLevelUpNotificationJob < ApplicationJob
queue_as :default
def perform(user_id, level)
UserNotification.add_level_up_notification(user_id, level)
rescue StandardError => e
Sentry.capture_exception(e, extra: { job: self.class.name })
Rails.logger.error("[#{self.class.name}] Failed: #{e.message}")
raise
end
end
endThe job accepts the integer user_id (not public_id) and the integer level. It delegates to UserNotification.add_level_up_notification, rescuing any StandardError to log it (Sentry + Rails logger) before re-raising so Sidekiq's retry behavior still applies. In development and test the job is called synchronously via perform_now. In production it is enqueued to Sidekiq's :default queue.
Frontend Components
XpRanksInfoModal (src/components/achievements/XpRanksInfoModal.vue)
An in-app education modal triggered by an info button on AchievementsHero. Explains all XP-earning actions and the full rank ladder. Opened by tapping the ⓘ icon on the hero card. All data is static and sourced from ionic_frontend/src/constants/gamification.ts, which serves as the single frontend source of truth for XP amounts and rank thresholds. See docs/features/achievements.md for user-facing description.
UserRankCard (src/components/achievements/UserRankCard.vue)
Displays the user's level badge, rank title, and an XP progress bar. Accepts four props:
| Prop | Type | Description |
|---|---|---|
name | String | Display name (falls back to 'Friend') |
level | Number | Current level integer |
rank | String | Rank title string (computed by parent) |
currentXP | Number | Current XP total |
nextLevelXP | Number | XP threshold for the next level |
The component computes progress as (currentXP / nextLevelXP) * 100, clamped to 100. The progress bar transitions with a 1-second CSS ease-out.
DoneCard (src/components/dashboard/streamlined/DoneCard.vue)
Session completion card displayed in Focus Mode after the user finishes a habit check-in session. Shows four stat chips: habits checked in, mood logged, current streak, and XP earned this session. The XP chip is calculated as xpEarned = habitsCheckedInSession × 25 (sourced from subscribers.rb:57) and appears only when xpEarned > 0. This chip provides immediate gamification feedback to mobile focus-mode users who may not visit the main dashboard or Achievements page.
DashboardHero (src/components/dashboard/DashboardHero.vue)
Dashboard momentum-tile strip showing streak count (or this week/completed/active/total progress, depending on active-goal count), each tile a tap-to-explain or tap-to-open-modal trigger — see dashboard.md § DashboardHero for the full per-tile contract. Does not display XP or level — that is the role of UserRankCard on the Achievements page. Superseded the standalone MomentumBar.vue, which is deleted (OBJ-3016).
AchievementWatcher (src/components/achievements/AchievementWatcher.vue)
Renderless component mounted in the app root. Polls USER_QUERY every 10 seconds via pollInterval and watches the actions array. Additionally, it registers a refetch callback with useAchievementRefetch so that mutations that earn badges (habit check-ins, goal completions, etc.) trigger immediate refetches instead of waiting up to 10 seconds. On mount, it loads the persisted set of acknowledged actions from localStorage keyed by userId. On each poll, it diffs the fetched actions against the persisted set and celebrates only newly earned badges, then persists the new set. This approach prevents duplicate celebrations on remount, across app restarts, and during onboarding flows (badges earned before the watcher mounts are captured on first poll). This is the bridge between backend badge creation and frontend celebration.
StreakCelebration (src/components/gamification/StreakCelebration.vue)
Celebratory overlay that fires automatically when a user increments their daily activity streak at ≥3 days. The component displays the current streak count, milestone badge (for key days: 3, 7, 14, 30, 100), and motivational copy, then auto-dismisses after a configurable delay (1.8–4 seconds depending on tier).
Animations:
- Flame entry — The 🔥 emoji scales from 0.7 → 1.22 → 1.0 over 400ms with spring easing (
cubic-bezier(0.34, 1.56, 0.64, 1)), accompanied by a drop-shadow glow that scales by rarity tier (common: 8px / 0.45 opacity, rare: 12px / 0.60, epic: 16px / 0.72, legendary: 22px / 0.85). The glow is warm gold for all tiers except epic, which uses accent orange for layering. - Flame sustain pulse — For epic (day 30) and legendary (day 100) milestones only, the flame pulses subtly after entry, scaling 1.0 → 1.07 → 1.0 over 2.4s (epic) or 2.0s (legendary) with
ease-in-out, creating a "breathing" effect that reinforces the milestone significance. - Number count-up — When a streak increments (e.g., 7 → 8), the counter animates from the prior value to the new value over 700ms with ease-out cubic easing (
1 - Math.pow(1 - t, 3)). The count-up begins 100ms after the flame entry starts, creating a staggered entrance. After the tween settles, the number "pops" briefly (scale 1.0 → 1.22 → 1.0, 150ms) with spring easing. The auto-dismiss timer does NOT start until after the pop completes, extending the time-on-screen by ~850ms when the count-up runs. WhenpriorStreakis undefined or equal to the current streak, the count-up is skipped and the number renders at the final value immediately.
Props:
streakDay(Number, required) — the current streak length in daysautoPlay(Boolean, default: true) — whether to show and auto-dismiss the celebrationpriorStreak(Number, optional) — the streak count before this increment; drives the count-up animation if set and less thanstreakDay
Accessibility:
- The flame span carries
aria-hidden="true"to keep the animated emoji out of the screen-reader tree; the parent div'saria-label="${streakDay}-day streak"provides the full semantic label, read only at final value. - The counter tween itself is not announced — screen readers do not have
aria-liveon the counter and read the final aria-label once, which is the correct behavior. prefers-reduced-motion: reduceusers: all animations are suppressed via CSS (animation: none) and the JS tween is skipped. The counter renders at the final value immediately and the dismiss timer fires with no delay.
Integration: Hooked into the reactive useStreakCelebration composable in Dashboard. When a streak increments, the composable captures the prior streak value and passes it as the priorStreak prop, triggering the count-up animation. The PostHog streak_celebration_shown event payload includes prior_streak when present (for funnel analysis).
useAchievementNotifications (src/composables/useAchievementNotifications.ts)
Manages a shared notification queue and celebration logic. Key exports:
| Function | Description |
|---|---|
showAchievementUnlocked(badgeKey) | Looks up the badge in BadgeCollection, resolves rarity, queues celebration. Common/Rare/Epic → toast with confetti at rarity-graded duration. Legendary → LegendaryCelebration modal (8s countdown, full-screen takeover). |
celebrateAchievement(badgeKey, skipToast?) | Fires confetti immediately. Optionally skips the toast (used for programmatic previews). |
showLevelUp(level) | Fires confetti scaled to level rarity and shows a "You reached Level N!" toast. Level ≤ 3 = common, 4–6 = rare, 7–9 = epic, 10+ = legendary. |
Celebration intensity scales with badge rarity:
| Rarity | Toast Duration | Visual Effect | Behavior |
|---|---|---|---|
| Common | 3.5s | Confetti (50 particles, 60° spread, 2s) | Toast only; auto-dismisses |
| Rare | 4.5s | Confetti (100 particles, 80° spread, 3s) | Toast only; auto-dismisses |
| Epic | 5.5s | Confetti (150 particles, 100° spread, 4s) | Toast only; auto-dismisses |
| Legendary | N/A | Full-screen modal with gold glow + countdown progressbar | LegendaryCelebration.vue full-screen overlay; 8s countdown; manual dismiss + "View badge" CTA |
Queue and stagger behavior:
- Non-Legendary toasts (Common/Rare/Epic) appear in a vertical stack with 300ms stagger between entries.
- If a Legendary badge is earned, it queues after all non-Legendary toasts.
- Multiple Legendary badges earn sequentially — each waits for the prior to auto-dismiss before showing.
- Reduced-motion fallback: Confetti is disabled and
pulse/scaleanimations are removed. Countdown bar is replaced with static "Tap anywhere to dismiss" text. - No sound at any tier (anti-social philosophy — sounds intrude on shared spaces like coworking or transit).
LegendaryCelebration (src/components/achievements/LegendaryCelebration.vue)
Full-screen modal celebrating the unlock of a Legendary badge. Mounted in App.vue and driven by module-level state from useAchievementNotifications. Renders a:
- Gradient backdrop with blur (
from-rarity-legendary/20 via-black/80 to-black/90) - Badge image (48px × 48px) with gold drop-shadow glow
- "✨ Legendary Badge" header with rarity color
- Badge name and achievement message
- 8-second countdown progress bar (with ARIA
role="progressbar") - "View badge" primary action →
/achievements?badge=<badgeKey>(opens BadgeModal) - "Keep going" dismiss button
- Reduced-motion fallback: countdown bar hidden, "Tap anywhere to dismiss" text shown
Uses useFocusTrap for keyboard accessibility (same pattern as BadgeModal.vue). Initial focus lands on "View badge" button. Click-outside and "Keep going" button both dismiss. Auto-dismisses after 8 seconds (exact delay gated on !reducedMotion).
Testing the Feature
Manual Testing Checklist
- [ ] Complete a habit check-in → verify XP increases by 25 (check via Achievements page XP bar or GraphQL playground)
- [ ] Complete a goal → verify XP increases by 500
- [ ] Sign in on consecutive days → verify daily streak XP of 50 is awarded on streak extension
- [ ] Award enough XP to cross a level threshold → verify the level badge on
UserRankCardincrements - [ ] Award XP that crosses multiple thresholds at once (use Rails console:
GamificationService.award_xp(user, 2000, 'test')) → verify level jumps to the correct value and multiple notifications fire - [ ] With an active accountability partnership, have both partners check in on the same day → verify each receives +25 XP labeled 'Partner Bonus'
- [ ] With an active partnership, have only one partner check in → verify no partner bonus is awarded
- [ ] Check in twice on the same day with a partnership → verify partner bonus is not double-awarded
- [ ] Verify that calling
award_xpwithnildoes not raise - [ ] Sign in on the same day twice → verify 50 XP streak award fires only once (streak does not increment on same-day second sign-in)
- [ ] Complete an already-completed goal → verify 500 XP does not fire again (
saved_change_to_completed?guard)
Backend Unit Tests
cd rails_api
# GamificationService — full coverage for award_xp, check_for_level_up, calculate_threshold
bundle exec rspec spec/services/gamification_service_spec.rb
# Interaction that publishes the habit_checked_in DomainEvent (XP is awarded by the subscriber, not here)
bundle exec rspec spec/interactions/goal_tracking/check_in_habit_spec.rb
# Partner bonus interaction (co-op XP)
bundle exec rspec spec/interactions/gamification/award_partner_bonus_spec.rb
# Interaction that awards goal completion XP
bundle exec rspec spec/interactions/goal_tracking/update_goal_spec.rb
# Level-up notification job
bundle exec rspec spec/jobs/gamification/create_level_up_notification_job_spec.rbFrontend Unit Tests
cd ionic_frontend
# UserRankCard renders level, rank, and progress bar
npm run test:unit -- --run -t "UserRankCard"
# Achievement notification queue and confetti logic
npm run test:unit -- --run -t "useAchievementNotifications"
# AchievementWatcher new-action diffing logic
npm run test:unit -- --run -t "AchievementWatcher"GraphQL API Reference
User XP and Level Fields
query UserQuery($id: ID!) {
user(id: $id) {
xp # Integer — total XP earned
level # Integer — current level
nextLevelThreshold # Integer — XP required to reach the next level
progressToNextLevel # Float — 0.0–100.0 percentage toward next level
streak # Integer — current consecutive day streak
longestStreak # Integer — all-time longest streak
actions {
id
action # Badge key string (e.g., "first_sign_in")
acknowledged
createdAtTime
}
}
}nextLevelThreshold and progressToNextLevel are computed by User#next_level_threshold and User#progress_to_next_level respectively, both of which delegate to GamificationService.calculate_threshold.
Delivery mechanism
There is no actionUpdate subscription — Subscription only defines notificationUpdate (see docs/architecture/graphql/types.md#subscription), and UserAction.generate has no ActionCable trigger. New badge unlocks are delivered by polling: AchievementWatcher.vue runs the UserQuery above on a 10-second pollInterval and diffs the returned actions array for entries not yet seen.
Known Behaviors & Edge Cases
niluser guard:award_xpreturns immediately ifuseris nil. This prevents crashes in paths where a current_user lookup may return nil. No exception is raised and no logging occurs for the nil case.XP is cumulative, not per-level:
user.xpstores total lifetime XP, not XP within the current level.progress_to_next_levelsubtracts the current level's threshold from total XP before calculating the percentage.Level is stored, not derived:
user.levelis persisted to the database byaward_xp. If XP is manually adjusted in the database without callingaward_xp, the level will be stale. Re-runGamificationService.check_for_level_up(user); user.save!to re-sync.Level-up job is environment-aware:
CreateLevelUpNotificationJobrunsperform_nowin development and test, andperform_laterin production. Tests assert onperform_nowspecifically — do not stub the timing selection in specs.Streak XP fires on extension only:
update_streak!comparesnew_active_streak > current_streakbefore awarding XP. A user who signs in twice on the same day does not receive a second 50 XP award.Goal completion guard:
UpdateGoalusesgoal.saved_change_to_completed?to check whether thecompletedattribute actually changed in the current save. Re-saving an already-completed goal does not re-award 500 XP.Rank titles live on the frontend: There is no
rankcolumn on theuserstable and no backend method for rank titles. The rank string is computed inAchievements.vuefromuserLevel. If rank titles change, only that file needs updating — no backend migration required.Multi-level-up notifications are ordered:
check_for_level_upincrements level and enqueues a notification in each loop iteration, in ascending level order. If Sidekiq processes jobs out of order in production, the user may see level-up notifications arrive non-sequentially.Celebration moment is immediate within 2 seconds: Badge acquisitions trigger an immediate
refetchAchievements()call (instead of waiting 10s for the next poll interval) after mutations that can earn badges (habit check-ins, goal completions, community joins, etc.). This ensures the celebration fires within ~2s of the user action, honoring the "immediate feedback" brand principle. The 10-second poll remains as a safety net for badges earned outside the app or across multiple windows.AchievementWatcher uses localStorage for dedup: The watcher persists the last-seen achievement set in
localStoragekeyed byuserId. This prevents duplicate celebrations on: app remounts, browser refreshes, and across multiple tabs (single-tab scope). Badges earned during sign-in or onboarding (before the watcher mounts) are captured and celebrated on the first poll after mount, not silently absorbed. Note: moving between devices will re-celebrate on the new device (acceptable trade-off).Legendary badges take priority in queue: If both Common and Legendary badges are earned in the same moment (e.g., onboarding completing a milestone that grants multiple badges), the Common/Rare/Epic toasts appear first (with stagger), and the Legendary modal fires after the queue clears. This prevents the full-screen modal from being dismissed immediately by stacked toasts.
Last updated: 2026-09-19 (OBJ-3977: dropped the stale "gold" descriptor from the UserRankCard progress-bar description, since the fill token is no longer hardcoded gold)