Skip to content
Merged
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
28 changes: 23 additions & 5 deletions backend/__tests__/integration/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand All @@ -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');

Expand All @@ -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');

Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -132,6 +149,7 @@ describe('Slash Commands Integration Tests', () => {
};

mockGetUserStats.mockReturnValue(mockStats);
mockGetDetailedSenseiBreakdown.mockReturnValue({ reactions: 70, uniqueReactors: 5 });

await executeStats(interaction as any);

Expand Down
116 changes: 80 additions & 36 deletions backend/src/commands/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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;

Expand All @@ -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) {
Expand All @@ -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);
Expand Down
31 changes: 31 additions & 0 deletions backend/src/services/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,37 @@ export async function getReactionBreakdown(userId: string): Promise<ReactionCoun
};
}

/**
* Get detailed Sensei reaction breakdown within a time window
*/
export interface DetailedRoleBreakdown {
reactions: number;
uniqueReactors: number;
}
export async function getDetailedSenseiBreakdown(
userId: string,
days: number
): Promise<DetailedRoleBreakdown> {
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)
*/
Expand Down