diff --git a/src/app/actions/recommendations.ts b/src/app/actions/recommendations.ts index 6908b748..f7453fea 100644 --- a/src/app/actions/recommendations.ts +++ b/src/app/actions/recommendations.ts @@ -305,6 +305,14 @@ export async function skipRecommendation( // Insert a replacement pick. Same difficulty if possible. Excludes // anything the user has already seen (any status). + const { data: profile, error: profileErr } = await service + .from('profiles') + .select('level') + .eq('id', user.id) + .single(); + + if (profileErr) return err('persist_failed', profileErr.message); + const { data: profile } = await service .from('profiles') .select('level') @@ -338,6 +346,14 @@ async function pickReplacement(args: { .eq('user_id', userId); const excludeIds = new Set((seen ?? []).map((r) => r.issue_id)); + const allowedDifficulties = new Set(); + if (userLevel >= 0) allowedDifficulties.add('E'); + if (userLevel >= 1) allowedDifficulties.add('M'); + if (userLevel >= 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 +364,11 @@ 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..c9446adb 100644 --- a/src/lib/pipeline/recommend.test.ts +++ b/src/lib/pipeline/recommend.test.ts @@ -105,6 +105,21 @@ 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' })]; 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