# 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<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.

```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<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.

```typescript
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.

```typescript
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.

```typescript
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

```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
