diff --git a/backend/__tests__/integration/commands.test.ts b/backend/__tests__/integration/commands.test.ts index 5989b54..78d9175 100644 --- a/backend/__tests__/integration/commands.test.ts +++ b/backend/__tests__/integration/commands.test.ts @@ -10,17 +10,20 @@ import { createMockInteraction, createMockUser } from '../mocks/discord.js'; const mockGetReactionBreakdown = jest.fn(); const mockGetLeaderboard = jest.fn(); const mockGetReactionsForUser = jest.fn(); +const mockGetDetailedSenseiBreakdown = jest.fn(); const mockGetUserStats = jest.fn(); const mockCalculateSenpaiScore = jest.fn(); const mockCalculateSenseiScore = jest.fn(); const mockCheckPromotion = jest.fn(); const mockGetSenseiDecayStatus = jest.fn(); const mockCheckSenseiDecay = jest.fn(); +const mockGetRoleCounts = jest.fn(); jest.unstable_mockModule('../../src/services/database.js', () => ({ getReactionBreakdown: mockGetReactionBreakdown, getLeaderboard: mockGetLeaderboard, getReactionsForUser: mockGetReactionsForUser, + getDetailedSenseiBreakdown: mockGetDetailedSenseiBreakdown, })); jest.unstable_mockModule('../../src/services/reputation.js', () => ({ @@ -35,6 +38,16 @@ jest.unstable_mockModule('../../src/services/decay.js', () => ({ checkSenseiDecay: mockCheckSenseiDecay, })); +jest.unstable_mockModule('../../src/services/roleManager.js', () => ({ + getRoleCounts: mockGetRoleCounts, +})); + +jest.unstable_mockModule('../../src/utils/config.js', () => ({ + config: { + decayWindowDays: 360, + }, +})); + const { execute: executeStats } = await import('../../src/commands/stats.js'); const { execute: executeLeaderboard } = await import('../../src/commands/leaderboard.js'); @@ -44,6 +57,10 @@ describe('Slash Commands Integration Tests', () => { }); describe('/stats command', () => { + beforeEach(() => { + mockGetRoleCounts.mockReturnValue({ kohai: 100, senpai: 50, sensei: 20, meijin: 2 }); + }); + test('should display stats for Kohai user', async () => { const interaction = createMockInteraction('user-1', 'stats'); @@ -72,10 +89,8 @@ describe('Slash Commands Integration Tests', () => { expect(interaction.deferReply).toHaveBeenCalled(); expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('Kohai')); - expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('38')); - expect(interaction.editReply).toHaveBeenCalledWith( - expect.stringContaining('Progress to Senpai') - ); + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('reactions from')); + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('To Advance')); }); test('should display stats for Sensei user with decay info', async () => { @@ -100,11 +115,13 @@ describe('Slash Commands Integration Tests', () => { mockGetUserStats.mockReturnValue(mockStats); mockGetSenseiDecayStatus.mockReturnValue(mockDecayStatus); + mockGetDetailedSenseiBreakdown.mockReturnValue({ reactions: 42, uniqueReactors: 12 }); await executeStats(interaction as any); expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('Sensei')); - expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('42/30')); + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('last 360 days')); + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('maintained')); }); test('should display stats for specified user', async () => { @@ -132,6 +149,7 @@ describe('Slash Commands Integration Tests', () => { }; mockGetUserStats.mockReturnValue(mockStats); + mockGetDetailedSenseiBreakdown.mockReturnValue({ reactions: 70, uniqueReactors: 5 }); await executeStats(interaction as any); diff --git a/backend/src/commands/stats.ts b/backend/src/commands/stats.ts index 825a48a..f5c5074 100644 --- a/backend/src/commands/stats.ts +++ b/backend/src/commands/stats.ts @@ -2,6 +2,9 @@ import { ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js'; import { Role } from '../types.js'; import { getUserStats } from '../services/reputation.js'; import { getSenseiDecayStatus } from '../services/decay.js'; +import { getRoleCounts } from '../services/roleManager.js'; +import { getDetailedSenseiBreakdown } from '../services/database.js'; +import { config } from '../utils/config.js'; export const data = new SlashCommandBuilder() .setName('stats') @@ -14,7 +17,6 @@ export async function execute(interaction: ChatInputCommandInteraction): Promise try { await interaction.deferReply({ ephemeral: true }); - // Get target user (default to command invoker) const targetUser = interaction.options.getUser('user') || interaction.user; const guild = interaction.guild; @@ -23,7 +25,6 @@ export async function execute(interaction: ChatInputCommandInteraction): Promise return; } - // Get user stats const stats = await getUserStats(guild, targetUser.id); if (!stats.currentRole) { @@ -33,45 +34,88 @@ export async function execute(interaction: ChatInputCommandInteraction): Promise return; } - // Build stats message - let message = `🎌 **Reputation Stats for ${targetUser.tag}**\n\n`; - message += `**Current Role:** ${stats.currentRole}\n`; - message += `**Total :dojo: reactions:** ${stats.breakdown.total}\n`; - message += ` - From Kōhai: ${stats.breakdown.fromKohai} (display only)\n`; - message += ` - From Senpai: ${stats.breakdown.fromSenpai}\n`; - message += ` - From Sensei: ${stats.breakdown.fromSensei}\n\n`; + const roleCounts = getRoleCounts(guild); - // Show progress based on current role - if (stats.currentRole === Role.Kohai && stats.senpaiScore) { + let message = `**Reputation Stats for ${targetUser.tag}**\n\n`; + message += `**Current Role:** ${stats.currentRole}\n\n`; + + // Build breakdown section based on role + if (stats.currentRole === Role.Sensei) { + // Sensei: show only last 360 days + const senseiBreakdown = await getDetailedSenseiBreakdown( + targetUser.id, + config.decayWindowDays + ); + const senseiPct = + roleCounts.sensei > 0 + ? Math.round((senseiBreakdown.uniqueReactors / roleCounts.sensei) * 100) + : 0; + + message += `**:dojo: Reactions (last ${config.decayWindowDays} days):**\n`; + message += ` ${senseiBreakdown.reactions} reactions from ${senseiPct}% of Sensei\n\n`; + + // Decay status + const decayStatus = await getSenseiDecayStatus(targetUser.id); + if (decayStatus.recentCount >= decayStatus.threshold) { + message += `**Status:** Sensei maintained ✅`; + } else { + const needed = decayStatus.threshold - decayStatus.recentCount; + message += `**To Maintain:** ${needed} more Sensei reactions needed`; + } + } else if (stats.currentRole === Role.Kohai && stats.senpaiScore) { + // Kōhai: show combined Senpai+Sensei stats (both count for promotion) const score = stats.senpaiScore; - const reactionProgress = score.meetsThreshold - ? `${score.totalReactions}/${score.threshold} reactions ✅` - : `${score.totalReactions}/${score.threshold} reactions (${score.threshold - score.totalReactions} more needed)`; - const uniqueProgress = score.meetsUnique - ? `${score.uniqueReactors}/${score.uniqueRequired} unique reactors ✅` - : `${score.uniqueReactors}/${score.uniqueRequired} unique reactors (${score.uniqueRequired - score.uniqueReactors} more needed)`; - - message += `**Progress to Senpai:** ${reactionProgress} | ${uniqueProgress}\n`; - message += `_(Requires ${score.threshold} reactions from ${score.uniqueRequired} unique Senpai/Sensei)_`; + const totalEligible = roleCounts.senpai + roleCounts.sensei; + const pct = totalEligible > 0 ? Math.round((score.uniqueReactors / totalEligible) * 100) : 0; + + message += `**:dojo: Reactions:**\n`; + message += ` ${score.totalReactions} reactions from ${pct}% of Senpai/Sensei\n\n`; + + if (score.meetsThreshold && score.meetsUnique) { + message += `**Status:** Ready for Senpai ✅`; + } else { + message += `**To Advance:** `; + const needs: string[] = []; + if (!score.meetsThreshold) { + needs.push(`${score.threshold - score.totalReactions} more reactions`); + } + if (!score.meetsUnique) { + needs.push(`${score.uniqueRequired - score.uniqueReactors} more unique Senpai/Sensei`); + } + message += needs.join(' and '); + } } else if (stats.currentRole === Role.Senpai && stats.senseiScore) { + // Senpai: show Sensei-only stats within decay window (only Sensei count for promotion) + const senseiBreakdown = await getDetailedSenseiBreakdown( + targetUser.id, + config.decayWindowDays + ); + const pct = + roleCounts.sensei > 0 + ? Math.round((senseiBreakdown.uniqueReactors / roleCounts.sensei) * 100) + : 0; + + message += `**:dojo: Reactions:**\n`; + message += ` ${senseiBreakdown.reactions} reactions from ${pct}% of Sensei in the last ${config.decayWindowDays} days\n\n`; + const score = stats.senseiScore; - const reactionProgress = score.meetsThreshold - ? `${score.totalReactions}/${score.threshold} reactions ✅` - : `${score.totalReactions}/${score.threshold} reactions (${score.threshold - score.totalReactions} more needed)`; - const uniqueProgress = score.meetsUnique - ? `${score.uniqueReactors}/${score.uniqueRequired} unique Sensei ✅` - : `${score.uniqueReactors}/${score.uniqueRequired} unique Sensei (${score.uniqueRequired - score.uniqueReactors} more needed)`; - - message += `**Progress to Sensei:** ${reactionProgress} | ${uniqueProgress}\n`; - message += `_(Requires ${score.threshold} reactions from ${score.uniqueRequired} unique Sensei)_`; - } else if (stats.currentRole === Role.Sensei) { - const decayStatus = await getSenseiDecayStatus(targetUser.id); - const status = - decayStatus.recentCount >= decayStatus.threshold - ? `${decayStatus.recentCount}/${decayStatus.threshold} ✅` - : `${decayStatus.recentCount}/${decayStatus.threshold} ⚠️`; + // Use time-windowed data for advancement check + const meetsThreshold = senseiBreakdown.reactions >= score.threshold; + const meetsUnique = senseiBreakdown.uniqueReactors >= score.uniqueRequired; - message += `**Sensei reactions (last ${decayStatus.windowDays} days):** ${status}`; + if (meetsThreshold && meetsUnique) { + message += `**Status:** Ready for Sensei ✅`; + } else { + message += `**To Advance:** `; + const needs: string[] = []; + if (!meetsThreshold) { + needs.push(`${score.threshold - senseiBreakdown.reactions} more Sensei reactions`); + } + if (!meetsUnique) { + needs.push(`${score.uniqueRequired - senseiBreakdown.uniqueReactors} more unique Sensei`); + } + message += needs.join(' and '); + } } await interaction.editReply(message); diff --git a/backend/src/services/database.ts b/backend/src/services/database.ts index 95e7afb..02584a2 100644 --- a/backend/src/services/database.ts +++ b/backend/src/services/database.ts @@ -194,6 +194,37 @@ export async function getReactionBreakdown(userId: string): Promise { + const cutoffTimestamp = Date.now() - days * 24 * 60 * 60 * 1000; + + const results = await sql<{ reactions: string; unique_reactors: string }[]>` + SELECT + COUNT(*) as reactions, + COUNT(DISTINCT reactor_id) as unique_reactors + FROM reactions + WHERE author_id = ${userId} + AND author_id != reactor_id + AND reactor_role = 'Sensei' + AND timestamp >= ${cutoffTimestamp} + `; + + const row = results[0]; + return { + reactions: parseInt(row?.reactions || '0', 10), + uniqueReactors: parseInt(row?.unique_reactors || '0', 10), + }; +} + /** * Get all users with reactions (for leaderboard) */