- 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.
470 lines
12 KiB
Markdown
470 lines
12 KiB
Markdown
# 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
|