# Gamification and progress service spec ## Responsibility Records session completions, calculates XP (commits), manages levels and streaks, evaluates badge conditions, generates milestone cards, and serves leaderboard data. All gamification data is public to all employees. --- ## Service location ``` app/services/progress/ ├── src/ │ ├── index.ts entry point, Fastify server │ ├── routes/ │ │ ├── completions.ts POST /complete │ │ ├── profile.ts GET /profile/:userId │ │ ├── leaderboard.ts GET /leaderboard │ │ └── feed.ts GET /feed │ ├── engine/ │ │ ├── xp.ts commit calculation per type │ │ ├── level.ts level thresholds + promotion │ │ ├── streak.ts streak evaluation │ │ ├── badges.ts badge condition evaluation │ │ └── milestone.ts milestone card generation │ └── lib/ │ └── pocketbase.ts ├── package.json ├── tsconfig.json └── .env.example ``` --- ## API surface ### POST /complete Called by the frontend when an employee completes a micro learning. Request: ```json { "userId": "string", "topicId": "string", "microLearningId": "string", "microLearningType": "string", "weekNumber": 7, "cycle": 1 } ``` Response: ```json { "commitsEarned": 15, "totalCommits": 342, "levelBefore": "junior", "levelAfter": "junior", "streakWeeks": 5, "newBadges": [ { "key": "deep_reader", "label": "Deep reader", "tier": "content" } ], "milestoneCard": null } ``` `newBadges` is empty array if no badges earned this completion. `milestoneCard` is populated only at weeks 13 and 26. --- ### GET /profile/:userId Returns full gamification profile for an employee. Response: ```json { "userId": "string", "displayName": "string", "totalCommits": 342, "level": "junior", "currentStreakWeeks": 5, "longestStreakWeeks": 8, "typesUsed": ["concept_explainer", "scenario_quiz", "how_to"], "typesNotUsed": ["case_study", "myth_vs_evidence"], "badges": [ { "key": "bronze_1", "label": "First commit", "tier": "bronze", "earnedAt": "..." } ], "heatmap": [ { "week": 1, "completions": 3 }, { "week": 2, "completions": 0 } ] } ``` Heatmap returns 26 entries per cycle. `completions` = number of micro learning types completed that week. --- ### GET /leaderboard Returns all employee gamification profiles for leaderboard rendering. Not paginated at 150 employees. Response: ```json { "employees": [ { "userId": "string", "displayName": "string", "totalCommits": 847, "currentStreakWeeks": 18, "typesUsedCount": 9, "badgeCount": 5, "level": "senior" } ] } ``` Sorted by totalCommits descending. Frontend renders as multi-dimension table — not a ranked list. --- ### GET /feed Returns recent milestone cards for the public activity feed. Most recent first. Response: ```json { "milestones": [ { "userId": "string", "displayName": "string", "cycle": 1, "week": 26, "totalCommits": 847, "streakWeeks": 18, "badges": ["on_streak", "shipped"], "createdAt": "string" } ] } ``` --- ## XP (commits) calculation Each micro learning type earns a different number of commits based on cognitive effort required. ```typescript const COMMITS_PER_TYPE: Record = { concept_explainer: 10, glossary_anchor: 10, misconceptions: 15, how_to: 15, flashcard_set: 15, comparison_card: 20, reflection_prompt: 20, scenario_quiz: 25, myth_vs_evidence: 25, case_study: 30, } ``` First completion of a type the employee has never used before: +5 bonus commits (rewards type diversity). Duplicate completion (same topic + same type in same cycle): 0 commits. Check session_completions before awarding — idempotent. --- ## Levels Thresholds based on cumulative commits across all cycles. ```typescript const LEVEL_THRESHOLDS = { intern: 0, junior: 100, medior: 300, senior: 600, staff: 1000, principal: 1500, } ``` Level evaluated after every commit update. Level can only increase — never decreases between cycles. --- ## Streak Counts consecutive weeks with at least one completion. Evaluation logic on every POST /complete: ```typescript const lastActiveWeek = profile.last_active_week const currentWeek = completionWeekNumber if (currentWeek === lastActiveWeek) { // Same week — streak unchanged } else if (currentWeek === lastActiveWeek + 1) { // Consecutive — increment streak += 1 } else { // Gap — reset to 1 streak = 1 } profile.last_active_week = currentWeek profile.longest_streak_weeks = Math.max(streak, profile.longest_streak_weeks) ``` Week number resets to 1 on cycle start. Streak does not reset on cycle transition — a streak spanning the cycle boundary is maintained. --- ## Badges ### Badge seed data Seeded into PocketBase badges collection at startup. ```typescript const BADGE_DEFINITIONS = [ { key: 'first_commit', tier: 'bronze', label: 'First commit', description: 'Complete any session' }, { key: 'five_sessions', tier: 'silver', label: 'Five sessions', description: 'Complete 5 sessions using 5 different micro learning types' }, { key: 'on_streak', tier: 'gold', label: 'On a streak', description: 'Complete 13 sessions without skipping a week' }, { key: 'shipped', tier: 'legendary', label: 'Shipped', description: 'Complete all 26 sessions using all 10 micro learning types' }, { key: 'governance_nerd', tier: 'content', label: 'Governance nerd', description: 'Complete all topics in the holacratic structure theme' }, { key: 'process_architect', tier: 'content', label: 'Process architect', description: 'Complete all topics in the internal processes theme' }, { key: 'deep_reader', tier: 'content', label: 'Deep reader', description: 'Use the case study micro learning type 5 or more times' }, { key: 'handbook_expert', tier: 'content', label: 'Handbook expert', description: 'Complete all topics in the employee handbook theme' }, { key: 'type_collector', tier: 'content', label: 'Type collector', description: 'Use all 10 micro learning types at least once' }, ] ``` Content badges are theme-specific. Theme association resolved at runtime by matching badge key to theme title pattern — not hardcoded to theme IDs. ```typescript const THEME_BADGE_PATTERNS: Record = { 'governance_nerd': 'holacrat', 'process_architect': 'process', 'handbook_expert': 'handbook', } ``` Case-insensitive substring match on theme title. --- ### Badge evaluation Run after every POST /complete. Check all conditions, award unearned badges. ```typescript async function evaluateBadges(userId: string, profile: GamificationProfile): Promise { const earnedKeys = await getEarnedBadgeKeys(userId) const newBadges: string[] = [] if (!earnedKeys.includes('first_commit')) { const count = await countCompletions(userId) if (count >= 1) newBadges.push('first_commit') } if (!earnedKeys.includes('five_sessions')) { const sessions = await countUniqueSessions(userId) if (sessions >= 5 && profile.types_used.length >= 5) newBadges.push('five_sessions') } if (!earnedKeys.includes('on_streak')) { if (profile.longest_streak_weeks >= 13) newBadges.push('on_streak') } if (!earnedKeys.includes('shipped')) { const cycleComplete = await isCycleComplete(userId, profile.current_cycle) if (cycleComplete && profile.types_used.length === 10) newBadges.push('shipped') } if (!earnedKeys.includes('deep_reader')) { const count = await countTypeCompletions(userId, 'case_study') if (count >= 5) newBadges.push('deep_reader') } if (!earnedKeys.includes('type_collector')) { if (profile.types_used.length === 10) newBadges.push('type_collector') } for (const [badgeKey, pattern] of Object.entries(THEME_BADGE_PATTERNS)) { if (!earnedKeys.includes(badgeKey)) { const complete = await isThemeComplete(userId, pattern) if (complete) newBadges.push(badgeKey) } } for (const key of newBadges) { await awardBadge(userId, key, profile.current_cycle) } return newBadges.map(k => BADGE_DEFINITIONS.find(b => b.key === k)!) } ``` --- ## Milestone cards Generated at weeks 13 and 26 of each cycle. ```typescript async function generateMilestoneCard( userId: string, weekNumber: number, cycle: number, profile: GamificationProfile ): Promise { if (weekNumber !== 13 && weekNumber !== 26) return null const exists = await milestoneExists(userId, cycle, weekNumber) if (exists) return null const badges = await getEarnedBadges(userId) return await pocketbase.collection('milestone_cards').create({ user: userId, cycle, week: weekNumber, total_commits: profile.total_commits, streak_weeks: profile.current_streak_weeks, badge_keys: badges.map(b => b.key), created_at: new Date().toISOString() }) } ``` --- ## Heatmap data Built from session_completions filtered by userId + cycle. Returns 26 entries — one per week. ```typescript async function buildHeatmap(userId: string, cycle: number): Promise { const completions = await pocketbase .collection('session_completions') .getFullList({ filter: `user="${userId}" && cycle=${cycle}` }) const byWeek = groupBy(completions, c => c.week_number) return Array.from({ length: 26 }, (_, i) => ({ week: i + 1, completions: byWeek[i + 1]?.length ?? 0 })) } ``` --- ## Environment variables ``` POCKETBASE_URL= POCKETBASE_ADMIN_EMAIL= POCKETBASE_ADMIN_PASSWORD= PROGRESS_PORT=3005 ``` --- ## Dependencies ```json { "dependencies": { "fastify": "^4", "pocketbase": "^0.21", "zod": "^3" } } ``` No AI calls. No Qdrant. No OpenAI. Pure business logic over PocketBase. --- ## TypeScript strict mode requirements - No `any` types - MicroLearningType as explicit string union - Badge keys as explicit string union matching BADGE_DEFINITIONS - COMMITS_PER_TYPE keyed by MicroLearningType — compile-time exhaustiveness - HeatmapEntry, MilestoneCard, BadgeDefinition typed explicitly --- ## What this service does NOT do - Does not generate content - Does not handle curriculum scheduling → curriculum service - Does not serve KB or micro learning data → frontend reads PocketBase - Does not handle auth → PocketBase + frontend --- ## Testing checkpoints 1. POST /complete for new user → first_commit badge awarded, commits added 2. POST /complete same topic + type twice → 0 commits second call (idempotent) 3. Complete 5 sessions with 5 types → five_sessions badge awarded 4. Simulate 13 consecutive weekly completions → on_streak badge awarded 5. Skip a week → streak resets to 1 6. Complete all topics in a theme → content badge awarded 7. Use all 10 types → type_collector badge awarded 8. Complete week 13 → milestone_card created and returned in response 9. GET /leaderboard → all employees returned with correct fields 10. GET /feed → milestone cards ordered most recent first 11. Cycle transition: week 26 complete → streak spans boundary, level preserved, heatmap resets for new cycle