diff --git a/src/app/actions/recommendations.ts b/src/app/actions/recommendations.ts index 6908b748..27471a33 100644 --- a/src/app/actions/recommendations.ts +++ b/src/app/actions/recommendations.ts @@ -309,6 +309,8 @@ export async function skipRecommendation( .from('profiles') .select('level') .eq('id', user.id) + .single(); + .maybeSingle(); const userLevel = profile?.level ?? 0; @@ -316,6 +318,7 @@ export async function skipRecommendation( service, userId: user.id, preferDifficulty: data.difficulty as 'E' | 'M' | 'H', + level: userLevel, userLevel, }); @@ -327,6 +330,9 @@ async function pickReplacement(args: { service: NonNullable>; userId: string; preferDifficulty: 'E' | 'M' | 'H'; + level: number; +}): Promise { + const { service, userId, preferDifficulty, level } = args; userLevel: number; }): Promise { const { service, userId, preferDifficulty, userLevel } = args; @@ -338,6 +344,14 @@ async function pickReplacement(args: { .eq('user_id', userId); const excludeIds = new Set((seen ?? []).map((r) => r.issue_id)); + const allowedDifficulties = new Set(); + if (level >= 0) allowedDifficulties.add('E'); + if (level >= 1) allowedDifficulties.add('M'); + if (level >= 2) allowedDifficulties.add('H'); + + // Try same tier first, then any allowed tier + for (const fallback of [false, true]) { + let q = service // Try same tier first, then any tier. Health >= 40 filter mirrors filterAndRank. for (const where of [{ difficulty: preferDifficulty }, null]) { const q = service @@ -348,6 +362,12 @@ async function pickReplacement(args: { .in('difficulty', where ? [where.difficulty] : allowedDifficulties) .order('scored_at', { ascending: false }) .limit(50); + + if (!fallback) { + q = q.eq('difficulty', preferDifficulty); + } else { + q = q.in('difficulty', Array.from(allowedDifficulties)); + } const { data: pool } = await q; const pick = (pool ?? []).find((i) => !excludeIds.has(i.id)); if (!pick) continue; diff --git a/src/lib/pipeline/recommend.test.ts b/src/lib/pipeline/recommend.test.ts index 3c493fd2..83b55c21 100644 --- a/src/lib/pipeline/recommend.test.ts +++ b/src/lib/pipeline/recommend.test.ts @@ -105,6 +105,23 @@ describe('filterAndRank', () => { expect(result).toEqual([]); }); + it('falls back to other allowed tiers but respects max difficulty cap', () => { + const issues = [ + issue({ id: 1, difficulty: 'H' }), + issue({ id: 2, difficulty: 'M' }), + issue({ id: 3, difficulty: 'E' }), + ]; + const result = filterAndRank(issues, { + level: 1, + excludeIssueIds: new Set(), + allowFallback: true, + }); + expect(result).toHaveLength(2); + expect(result.map((r) => r.difficulty).sort()).toEqual(['E', 'M']); + }); + + it('does not fallback to higher difficulty tiers', () => { + const issues = [issue({ id: 1, difficulty: 'M' }), issue({ id: 2, difficulty: 'M' })]; it('does not fallback to higher difficulty tiers', () => { const issues = [issue({ id: 1, difficulty: 'M' }), issue({ id: 2, difficulty: 'M' })]; diff --git a/src/lib/pipeline/recommend.ts b/src/lib/pipeline/recommend.ts index 8363b301..6de14b13 100644 --- a/src/lib/pipeline/recommend.ts +++ b/src/lib/pipeline/recommend.ts @@ -100,6 +100,14 @@ export function filterAndRank(pool: readonly ScoredIssue[], opts: RecommendOptio // Fallback: if any tier came up empty, optionally borrow from adjacent (only easier). if (opts.allowFallback && result.length < totalDesired(mix)) { const seen = new Set(result.map((r) => r.id)); + const allowedDifficulties = new Set(); + if (opts.level >= 0) allowedDifficulties.add('E'); + if (opts.level >= 1) allowedDifficulties.add('M'); + if (opts.level >= 2) allowedDifficulties.add('H'); + + const extras = eligible + .filter((i) => !seen.has(i.id) && allowedDifficulties.has(i.difficulty)) + .sort((a, b) => rankScore(b) - rankScore(a)); const allowedDifficulties = getAllowedDifficulties(opts.level); const extras = eligible