Files
learning-platform/docs/gamification-spec.md
RaymondVerhoef 472685f0d7 Add specifications for gamification, generation, and R42 chat services
- Introduced gamification service spec detailing responsibilities, API surface, XP calculation, levels, streaks, badges, milestone cards, and heatmap data.
- Added generation service spec outlining the process for generating micro learning content, including API endpoints, AI call configuration, prompt strategies, and error handling.
- Created R42 chat service spec covering chatbot interactions, retrieval pipeline, prompt construction, response generation, and stateless design principles.
2026-05-23 18:13:08 +02:00

12 KiB

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:

{
  "userId": "string",
  "topicId": "string",
  "microLearningId": "string",
  "microLearningType": "string",
  "weekNumber": 7,
  "cycle": 1
}

Response:

{
  "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:

{
  "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:

{
  "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:

{
  "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.

const COMMITS_PER_TYPE: Record<MicroLearningType, number> = {
  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.

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:

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.

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.

const THEME_BADGE_PATTERNS: Record<string, string> = {
  '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.

async function evaluateBadges(userId: string, profile: GamificationProfile):
  Promise<BadgeDefinition[]> {

  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.

async function generateMilestoneCard(
  userId: string,
  weekNumber: number,
  cycle: number,
  profile: GamificationProfile
): Promise<MilestoneCard | null> {

  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.

async function buildHeatmap(userId: string, cycle: number):
  Promise<HeatmapEntry[]> {

  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

{
  "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