Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@
## 2026-07-16 - [O(N*M) Nested Loop Lookups in Grading Engines]
**Learning:** In interactive scenarios (such as Bid Elevator and STR Triage), grading engines frequently iterate over user decisions and match them against scenario properties (like keywords or search terms). Performing `array.find()` inside loop bodies or filter predicates results in costly O(N*M) lookups.
**Action:** Convert arrays to `Map` lookups before entering loops/nested scans. Mapping keys once in O(M) time enables O(1) lookups during execution, transforming the time complexity of the grading logic to O(N + M).

## 2026-08-07 - [Collapsing Duplicate DB Queries in Sequential Loops]
**Learning:** Evaluators checking multiple rules (such as `checkCriteria` in the badge engine) can generate redundant database queries for identical user records or resource aggregates when looping over each rule. These can be optimized with a transient local cache (caching query promises rather than resolved values) during the evaluation lifecycle.
**Action:** Use a transient, local `EvaluationCache` to cache query promises, safely collapsing database roundtrips from O(R) to O(1) where R is the number of rules checked.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,5 @@
"eslint --fix"
]
},
"packageManager": "pnpm@11.13.0+sha512.88d94724d8f2e6c186744a5584c6e59ecac869ec7ba15e9cb4cd628e8dc7066820b2481d8ee3b51ea8da323a7378068aa58c556a3720d32b7c20a051d088363a"
"packageManager": "pnpm@11.14.0+sha512.66c1ac4c7d4762d6d7dde44c7f3e5a73591ed0a0806e751d4ed32d4f004f25b2285a906b1fd8a9e3e621df3b4e2858bf88e50e0cf626bedbe977fe434a5caf85"
}
88 changes: 88 additions & 0 deletions src/lib/__tests__/badges.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,92 @@ describe('badges.ts', () => {
const result = await evaluateBadges('user-1', { trigger: 'login' });
expect(result.awarded).toEqual([]);
});

it('awards module_complete badge when criteria met', async () => {
(db.badge.findMany as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 'b1', title: 'Module Complete', slug: 'mod-comp', criteria: JSON.stringify({ type: 'module_complete', threshold: 3 }), xpReward: 20, description: '', icon: '', tier: 'BRONZE', isPublished: true, deletedAt: null },
]);
(db.lessonProgress.count as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(5);
const result = await evaluateBadges('user-1', { trigger: 'lesson_complete' });
expect(result.awarded).toHaveLength(1);
expect(result.totalXpGained).toBe(20);
expect(db.lessonProgress.count).toHaveBeenCalledTimes(1);
});

it('does not award module_complete badge when criteria not met', async () => {
(db.badge.findMany as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 'b1', title: 'Module Complete', slug: 'mod-comp', criteria: JSON.stringify({ type: 'module_complete', threshold: 3 }), xpReward: 20, description: '', icon: '', tier: 'BRONZE', isPublished: true, deletedAt: null },
]);
(db.lessonProgress.count as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(1);
const result = await evaluateBadges('user-1', { trigger: 'lesson_complete' });
expect(result.awarded).toEqual([]);
});

it('handles quiz_score badge correctly', async () => {
(db.badge.findMany as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 'b1', title: 'Quiz Whiz', slug: 'quiz', criteria: JSON.stringify({ type: 'quiz_score', threshold: 90 }), xpReward: 15, description: '', icon: '', tier: 'BRONZE', isPublished: true, deletedAt: null },
]);

// Case 1: different trigger
let result = await evaluateBadges('user-1', { trigger: 'login' });
expect(result.awarded).toEqual([]);

// Case 2: passed is false
result = await evaluateBadges('user-1', { trigger: 'quiz_submit', score: 100, passed: false });
expect(result.awarded).toEqual([]);

// Case 3: passed true, but score too low
result = await evaluateBadges('user-1', { trigger: 'quiz_submit', score: 80, passed: true });
expect(result.awarded).toEqual([]);

// Case 4: passed true, score met
result = await evaluateBadges('user-1', { trigger: 'quiz_submit', score: 95, passed: true });
expect(result.awarded).toHaveLength(1);
expect(result.totalXpGained).toBe(15);
});

it('awards tool_sessions badge with and without scope.toolType correctly', async () => {
(db.badge.findMany as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 'b1', title: 'Tools Generic', slug: 'tools-generic', criteria: JSON.stringify({ type: 'tool_sessions', threshold: 2 }), xpReward: 25, description: '', icon: '', tier: 'BRONZE', isPublished: true, deletedAt: null },
{ id: 'b2', title: 'Tools Specific', slug: 'tools-specific', criteria: JSON.stringify({ type: 'tool_sessions', threshold: 3, scope: { toolType: 'CAMPAIGN_BUILDER' } }), xpReward: 35, description: '', icon: '', tier: 'SILVER', isPublished: true, deletedAt: null },
]);

(db.toolSession.count as unknown as ReturnType<typeof vi.fn>).mockImplementation(async (query: any) => {
const toolType = query?.where?.toolType;
if (toolType === 'CAMPAIGN_BUILDER') {
return 4;
}
return 2;
});

const result = await evaluateBadges('user-1', { trigger: 'tool_submit', toolType: 'CAMPAIGN_BUILDER', passed: true });
expect(result.awarded).toHaveLength(2);
expect(result.totalXpGained).toBe(60);
});

it('deduplicates database queries using EvaluationCache', async () => {
(db.badge.findMany as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 'b1', title: 'Streak 1', slug: 'streak-1', criteria: JSON.stringify({ type: 'streak_days', threshold: 5 }), xpReward: 10, description: '', icon: '', tier: 'BRONZE', isPublished: true, deletedAt: null },
{ id: 'b2', title: 'Streak 2', slug: 'streak-2', criteria: JSON.stringify({ type: 'streak_days', threshold: 10 }), xpReward: 20, description: '', icon: '', tier: 'SILVER', isPublished: true, deletedAt: null },
{ id: 'b3', title: 'XP 1', slug: 'xp-1', criteria: JSON.stringify({ type: 'xp_threshold', threshold: 100 }), xpReward: 10, description: '', icon: '', tier: 'BRONZE', isPublished: true, deletedAt: null },
{ id: 'b4', title: 'XP 2', slug: 'xp-2', criteria: JSON.stringify({ type: 'xp_threshold', threshold: 200 }), xpReward: 20, description: '', icon: '', tier: 'SILVER', isPublished: true, deletedAt: null },
{ id: 'b5', title: 'Module 1', slug: 'mod-1', criteria: JSON.stringify({ type: 'module_complete', threshold: 1 }), xpReward: 10, description: '', icon: '', tier: 'BRONZE', isPublished: true, deletedAt: null },
{ id: 'b6', title: 'Module 2', slug: 'mod-2', criteria: JSON.stringify({ type: 'module_complete', threshold: 2 }), xpReward: 20, description: '', icon: '', tier: 'SILVER', isPublished: true, deletedAt: null },
]);

(db.user.findUnique as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({ streakDays: 7, xp: 150 });
(db.lessonProgress.count as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(1);

const result = await evaluateBadges('user-1', { trigger: 'login' });
// streak-1 is met, streak-2 is not. xp-1 is met, xp-2 is not. mod-1 is met, mod-2 is not.
// Total awarded: streak-1, xp-1, mod-1.
expect(result.awarded).toHaveLength(3);

// Verifying EvaluationCache collapsing:
// Even though 4 badges (streak-1, streak-2, xp-1, xp-2) checked the user, `db.user.findUnique` should only have been called once.
expect(db.user.findUnique).toHaveBeenCalledTimes(1);

// Even though 2 badges checked the lesson completed count, `db.lessonProgress.count` should only have been called once.
expect(db.lessonProgress.count).toHaveBeenCalledTimes(1);
});
});
89 changes: 70 additions & 19 deletions src/lib/badges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,68 @@ export interface BadgeEvaluationResult {
totalXpGained: number;
}

/**
* Transient cache to collapse duplicate database queries into a single promise
* during a badge evaluation run.
Comment on lines +54 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use plain definitions for the new cache behavior.

The new text uses terms such as “transient cache” and “query promises” without definitions.

  • src/lib/badges.ts#L54-L56: Explain that the cache exists only during one badge evaluation and reuses the same database request.
  • .jules/bolt.md#L7-L9: Replace or define “transient,” “query promises,” and “evaluation lifecycle” in direct language.

As per coding guidelines, use direct, plain-spoken language for the Filipino VA audience and define jargon.

📍 Affects 2 files
  • src/lib/badges.ts#L54-L56 (this comment)
  • .jules/bolt.md#L7-L9
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/badges.ts` around lines 54 - 56, Rewrite the cache comment at
src/lib/badges.ts lines 54-56 to say plainly that it is used only during one
badge evaluation and reuses the same database request. Update .jules/bolt.md
lines 7-9 to replace or directly define “transient,” “query promises,” and
“evaluation lifecycle” in language accessible to the Filipino VA audience; no
code behavior changes are needed.

Source: Coding guidelines

*/
class EvaluationCache {
private userId: string;
private completedCountPromise: Promise<number> | null = null;
private toolSessionsCountPromises = new Map<string, Promise<number>>();
private genericToolSessionsCountPromise: Promise<number> | null = null;
private userPromise: Promise<{ streakDays: number; xp: number } | null> | null = null;

constructor(userId: string) {
this.userId = userId;
}

getCompletedCount(): Promise<number> {
if (!this.completedCountPromise) {
this.completedCountPromise = db.lessonProgress.count({
where: { userId: this.userId, status: 'COMPLETED' },
});
}
return this.completedCountPromise;
}

getToolSessionsCount(toolType?: string): Promise<number> {
if (toolType) {
let p = this.toolSessionsCountPromises.get(toolType);
if (!p) {
p = db.toolSession.count({
where: {
userId: this.userId,
status: 'GRADED',
toolType,
},
});
this.toolSessionsCountPromises.set(toolType, p);
}
return p;
} else {
if (!this.genericToolSessionsCountPromise) {
this.genericToolSessionsCountPromise = db.toolSession.count({
where: {
userId: this.userId,
status: 'GRADED',
},
});
}
return this.genericToolSessionsCountPromise;
}
}

getUser(): Promise<{ streakDays: number; xp: number } | null> {
if (!this.userPromise) {
this.userPromise = db.user.findUnique({
where: { id: this.userId },
select: { streakDays: true, xp: true },
}) as Promise<{ streakDays: number; xp: number } | null>;
}
return this.userPromise;
}
}

Comment on lines +54 to +115

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add cache regression tests.

This change adds database-call caching, but this PR has no changed test segment. Add tests that evaluate multiple badges and assert that duplicate lesson, user, and same-scope tool-session queries execute once. Also assert that different toolType values use separate counts.

As per coding guidelines, “New features must include tests; admin and business-layer features require tests.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/badges.ts` around lines 54 - 115, Add regression tests for
EvaluationCache through the badge evaluation flow, covering multiple badges that
share lesson-progress, user, and same-scope tool-session lookups and asserting
each duplicate query executes once. Also verify badges using different toolType
values trigger separate tool-session counts, while preserving the existing badge
results.

Source: Coding guidelines

/**
* Evaluate all badges for a user against the current database state. Award any
* newly-earned ones. Idempotent — re-running with no new events returns
Expand Down Expand Up @@ -95,6 +157,8 @@ export async function evaluateBadges(
xpReward: number;
}> = [];

const cache = new EvaluationCache(userId);

for (const badge of published) {
if (alreadyAwardedSet.has(badge.id)) continue;

Expand All @@ -106,7 +170,7 @@ export async function evaluateBadges(
continue;
}

const qualifies = await checkCriteria(userId, criteria, event);
const qualifies = await checkCriteria(userId, criteria, event, cache);
if (qualifies) earnedNow.push(badge);
}

Expand Down Expand Up @@ -146,12 +210,11 @@ async function checkCriteria(
userId: string,
criteria: BadgeCriteria,
event: BadgeTrigger,
cache: EvaluationCache,
): Promise<boolean> {
switch (criteria.type) {
case 'module_complete': {
const completedCount = await db.lessonProgress.count({
where: { userId, status: 'COMPLETED' },
});
const completedCount = await cache.getCompletedCount();
// Treat each completed lesson as progress toward module_complete; the
// seed threshold is 1 so this triggers after the first lesson.
return completedCount >= criteria.threshold;
Expand All @@ -165,30 +228,18 @@ async function checkCriteria(

case 'tool_sessions': {
const scopeToolType = criteria.scope?.toolType;
const count = await db.toolSession.count({
where: {
userId,
status: 'GRADED',
...(scopeToolType ? { toolType: scopeToolType } : {}),
},
});
const count = await cache.getToolSessionsCount(scopeToolType);
return count >= criteria.threshold;
}

case 'streak_days': {
const user = await db.user.findUnique({
where: { id: userId },
select: { streakDays: true },
});
const user = await cache.getUser();
if (!user) return false;
return user.streakDays >= criteria.threshold;
}

case 'xp_threshold': {
const user = await db.user.findUnique({
where: { id: userId },
select: { xp: true },
});
const user = await cache.getUser();
if (!user) return false;
return user.xp >= criteria.threshold;
}
Expand Down