Skip to content

Commit 6e7704c

Browse files
Add leaderboard to stats
1 parent 5740682 commit 6e7704c

2 files changed

Lines changed: 338 additions & 0 deletions

File tree

src/coffeeChats/coffeeChatService.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -670,6 +670,41 @@ export const reportChannelStats = async (
670670
? ((uniqueParticipants.size / totalMembers) * 100).toFixed(2)
671671
: "0.0";
672672

673+
// Build leaderboard: count confirmed meetups per user across ALL time for this channel
674+
const allTimePairings = await CoffeeChatPairingModel.find({
675+
channelId: config.channelId,
676+
meetupConfirmed: true,
677+
});
678+
679+
const meetupCountByUser = new Map<string, number>();
680+
for (const pairing of allTimePairings) {
681+
for (const userId of pairing.userIds) {
682+
meetupCountByUser.set(userId, (meetupCountByUser.get(userId) ?? 0) + 1);
683+
}
684+
}
685+
686+
// Sort users by meetup count descending and take top 10
687+
const leaderboard = [...meetupCountByUser.entries()]
688+
.sort((a, b) => b[1] - a[1])
689+
.slice(0, 10);
690+
691+
const MEDALS = ["🥇", "🥈", "🥉"];
692+
const leaderboardLines = leaderboard.map(([userId, count], index) => {
693+
const medal = MEDALS[index] ?? `${index + 1}.`;
694+
return `${medal} <@${userId}> — *${count}* meetup${count !== 1 ? "s" : ""}`;
695+
});
696+
697+
const leaderboardBlock =
698+
leaderboardLines.length > 0
699+
? {
700+
type: "section" as const,
701+
text: {
702+
type: "mrkdwn" as const,
703+
text: `*🏆 All-Time Meetup Leaderboard:*\n${leaderboardLines.join("\n")}`,
704+
},
705+
}
706+
: null;
707+
673708
// Post stats to channel
674709
await slackbot.client.chat.postMessage({
675710
channel: config.channelId,
@@ -704,6 +739,7 @@ export const reportChannelStats = async (
704739
},
705740
],
706741
},
742+
...(leaderboardBlock ? [leaderboardBlock] : []),
707743
{
708744
type: "context",
709745
elements: [

tests/coffeeChats/coffeeChatService.test.ts

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1074,6 +1074,308 @@ describe("coffeeChatService", () => {
10741074

10751075
expect(mockPostMessage).not.toHaveBeenCalled();
10761076
});
1077+
1078+
it("should include a leaderboard block when there are all-time confirmed meetups", async () => {
1079+
const mockChannelId = "C12345";
1080+
const mockChannelName = "coffee-chats";
1081+
const mockPairingFrequencyDays = 14;
1082+
1083+
await new CoffeeChatConfigModel({
1084+
channelId: mockChannelId,
1085+
channelName: mockChannelName,
1086+
isActive: true,
1087+
pairingFrequencyDays: mockPairingFrequencyDays,
1088+
}).save();
1089+
1090+
// Pairing within the reporting window (counts toward period stats)
1091+
await new CoffeeChatPairingModel({
1092+
channelId: mockChannelId,
1093+
userIds: ["U1", "U2"],
1094+
createdAt: moment()
1095+
.tz("America/New_York")
1096+
.subtract(10, "days")
1097+
.toDate(),
1098+
dueDate: moment().tz("America/New_York").subtract(9, "days").toDate(),
1099+
meetupConfirmed: true,
1100+
}).save();
1101+
1102+
// Additional all-time confirmed pairings to build up the leaderboard
1103+
await new CoffeeChatPairingModel({
1104+
channelId: mockChannelId,
1105+
userIds: ["U1", "U3"],
1106+
createdAt: moment()
1107+
.tz("America/New_York")
1108+
.subtract(10, "days")
1109+
.toDate(),
1110+
dueDate: moment().tz("America/New_York").subtract(9, "days").toDate(),
1111+
meetupConfirmed: true,
1112+
}).save();
1113+
1114+
await coffeeChatService.reportStats();
1115+
1116+
const mockPostMessage =
1117+
jest.requireMock("../../src/slackbot").default.client.chat.postMessage;
1118+
1119+
expect(mockPostMessage).toHaveBeenCalledTimes(1);
1120+
1121+
const blocks: Array<{
1122+
type: string;
1123+
text?: { type: string; text: string };
1124+
}> = mockPostMessage.mock.calls[0][0].blocks;
1125+
1126+
// Leaderboard block must be present
1127+
const leaderboardBlock = blocks.find(
1128+
(b) =>
1129+
b.type === "section" &&
1130+
b.text?.text.includes("All-Time Meetup Leaderboard"),
1131+
);
1132+
expect(leaderboardBlock).toBeDefined();
1133+
1134+
// U1 appears in 2 confirmed pairings and must be ranked first
1135+
expect(leaderboardBlock?.text?.text).toContain("<@U1>");
1136+
expect(leaderboardBlock?.text?.text).toContain("🥇");
1137+
});
1138+
1139+
it("should rank leaderboard entries by meetup count descending", async () => {
1140+
const mockChannelId = "C12345";
1141+
const mockChannelName = "coffee-chats";
1142+
const mockPairingFrequencyDays = 14;
1143+
1144+
await new CoffeeChatConfigModel({
1145+
channelId: mockChannelId,
1146+
channelName: mockChannelName,
1147+
isActive: true,
1148+
pairingFrequencyDays: mockPairingFrequencyDays,
1149+
}).save();
1150+
1151+
const recentDueDate = moment()
1152+
.tz("America/New_York")
1153+
.subtract(1, "days")
1154+
.toDate();
1155+
const recentCreatedAt = moment()
1156+
.tz("America/New_York")
1157+
.subtract(10, "days")
1158+
.toDate();
1159+
1160+
// U_A: 3 meetups, U_B: 2 meetups, U_C: 1 meetup
1161+
for (let i = 0; i < 3; i++) {
1162+
await new CoffeeChatPairingModel({
1163+
channelId: mockChannelId,
1164+
userIds: ["U_A", `U_OTHER_${i}`],
1165+
createdAt: recentCreatedAt,
1166+
dueDate: recentDueDate,
1167+
meetupConfirmed: true,
1168+
}).save();
1169+
}
1170+
for (let i = 0; i < 2; i++) {
1171+
await new CoffeeChatPairingModel({
1172+
channelId: mockChannelId,
1173+
userIds: ["U_B", `U_EXTRA_${i}`],
1174+
createdAt: recentCreatedAt,
1175+
dueDate: recentDueDate,
1176+
meetupConfirmed: true,
1177+
}).save();
1178+
}
1179+
await new CoffeeChatPairingModel({
1180+
channelId: mockChannelId,
1181+
userIds: ["U_C", "U_LAST"],
1182+
createdAt: recentCreatedAt,
1183+
dueDate: recentDueDate,
1184+
meetupConfirmed: true,
1185+
}).save();
1186+
1187+
await coffeeChatService.reportStats();
1188+
1189+
const mockPostMessage =
1190+
jest.requireMock("../../src/slackbot").default.client.chat.postMessage;
1191+
1192+
const blocks: Array<{
1193+
type: string;
1194+
text?: { type: string; text: string };
1195+
}> = mockPostMessage.mock.calls[0][0].blocks;
1196+
1197+
const leaderboardBlock = blocks.find(
1198+
(b) =>
1199+
b.type === "section" &&
1200+
b.text?.text.includes("All-Time Meetup Leaderboard"),
1201+
);
1202+
1203+
expect(leaderboardBlock).toBeDefined();
1204+
1205+
const text = leaderboardBlock!.text!.text;
1206+
1207+
// U_A should appear before U_B, which should appear before U_C
1208+
expect(text.indexOf("<@U_A>")).toBeLessThan(text.indexOf("<@U_B>"));
1209+
expect(text.indexOf("<@U_B>")).toBeLessThan(text.indexOf("<@U_C>"));
1210+
1211+
// Gold medal goes to U_A
1212+
const aLine = text
1213+
.split("\n")
1214+
.find((line: string) => line.includes("<@U_A>"));
1215+
expect(aLine).toContain("🥇");
1216+
1217+
// Silver medal goes to U_B
1218+
const bLine = text
1219+
.split("\n")
1220+
.find((line: string) => line.includes("<@U_B>"));
1221+
expect(bLine).toContain("🥈");
1222+
});
1223+
1224+
it("should cap the leaderboard at 10 entries", async () => {
1225+
const mockChannelId = "C12345";
1226+
const mockChannelName = "coffee-chats";
1227+
const mockPairingFrequencyDays = 14;
1228+
1229+
await new CoffeeChatConfigModel({
1230+
channelId: mockChannelId,
1231+
channelName: mockChannelName,
1232+
isActive: true,
1233+
pairingFrequencyDays: mockPairingFrequencyDays,
1234+
}).save();
1235+
1236+
const recentDueDate = moment()
1237+
.tz("America/New_York")
1238+
.subtract(1, "days")
1239+
.toDate();
1240+
const recentCreatedAt = moment()
1241+
.tz("America/New_York")
1242+
.subtract(10, "days")
1243+
.toDate();
1244+
1245+
// Create 12 distinct users, each with 1 confirmed meetup
1246+
for (let i = 1; i <= 12; i++) {
1247+
await new CoffeeChatPairingModel({
1248+
channelId: mockChannelId,
1249+
userIds: [`U_TOP${i}`, `U_PARTNER${i}`],
1250+
createdAt: recentCreatedAt,
1251+
dueDate: recentDueDate,
1252+
meetupConfirmed: true,
1253+
}).save();
1254+
}
1255+
1256+
await coffeeChatService.reportStats();
1257+
1258+
const mockPostMessage =
1259+
jest.requireMock("../../src/slackbot").default.client.chat.postMessage;
1260+
1261+
const blocks: Array<{
1262+
type: string;
1263+
text?: { type: string; text: string };
1264+
}> = mockPostMessage.mock.calls[0][0].blocks;
1265+
1266+
const leaderboardBlock = blocks.find(
1267+
(b) =>
1268+
b.type === "section" &&
1269+
b.text?.text.includes("All-Time Meetup Leaderboard"),
1270+
);
1271+
1272+
expect(leaderboardBlock).toBeDefined();
1273+
1274+
// Only 10 lines after the header line
1275+
const lines = leaderboardBlock!
1276+
.text!.text.split("\n")
1277+
.filter((line: string) => line.includes("<@"));
1278+
expect(lines.length).toBe(10);
1279+
});
1280+
1281+
it("should omit the leaderboard block when there are no all-time confirmed meetups", async () => {
1282+
const mockChannelId = "C12345";
1283+
const mockChannelName = "coffee-chats";
1284+
const mockPairingFrequencyDays = 14;
1285+
1286+
await new CoffeeChatConfigModel({
1287+
channelId: mockChannelId,
1288+
channelName: mockChannelName,
1289+
isActive: true,
1290+
pairingFrequencyDays: mockPairingFrequencyDays,
1291+
}).save();
1292+
1293+
// One period pairing that is NOT confirmed — still triggers the stats post
1294+
await new CoffeeChatPairingModel({
1295+
channelId: mockChannelId,
1296+
userIds: ["U1", "U2"],
1297+
createdAt: moment()
1298+
.tz("America/New_York")
1299+
.subtract(10, "days")
1300+
.toDate(),
1301+
dueDate: moment().tz("America/New_York").subtract(9, "days").toDate(),
1302+
meetupConfirmed: false,
1303+
}).save();
1304+
1305+
await coffeeChatService.reportStats();
1306+
1307+
const mockPostMessage =
1308+
jest.requireMock("../../src/slackbot").default.client.chat.postMessage;
1309+
1310+
expect(mockPostMessage).toHaveBeenCalledTimes(1);
1311+
1312+
const blocks: Array<{
1313+
type: string;
1314+
text?: { type: string; text: string };
1315+
}> = mockPostMessage.mock.calls[0][0].blocks;
1316+
1317+
const leaderboardBlock = blocks.find(
1318+
(b) =>
1319+
b.type === "section" &&
1320+
b.text?.text.includes("All-Time Meetup Leaderboard"),
1321+
);
1322+
expect(leaderboardBlock).toBeUndefined();
1323+
});
1324+
1325+
it("should not include meetups from other channels in the leaderboard", async () => {
1326+
const mockChannelId = "C12345";
1327+
const otherChannelId = "C_OTHER";
1328+
const mockPairingFrequencyDays = 14;
1329+
1330+
await new CoffeeChatConfigModel({
1331+
channelId: mockChannelId,
1332+
channelName: "coffee-chats",
1333+
isActive: true,
1334+
pairingFrequencyDays: mockPairingFrequencyDays,
1335+
}).save();
1336+
1337+
// Pairing in the target channel
1338+
await new CoffeeChatPairingModel({
1339+
channelId: mockChannelId,
1340+
userIds: ["U1", "U2"],
1341+
createdAt: moment()
1342+
.tz("America/New_York")
1343+
.subtract(10, "days")
1344+
.toDate(),
1345+
dueDate: moment().tz("America/New_York").subtract(9, "days").toDate(),
1346+
meetupConfirmed: true,
1347+
}).save();
1348+
1349+
// Pairing in a different channel — U_OTHER should NOT appear in leaderboard
1350+
await new CoffeeChatPairingModel({
1351+
channelId: otherChannelId,
1352+
userIds: ["U_OTHER", "U_OTHER2"],
1353+
createdAt: moment()
1354+
.tz("America/New_York")
1355+
.subtract(10, "days")
1356+
.toDate(),
1357+
dueDate: moment().tz("America/New_York").subtract(9, "days").toDate(),
1358+
meetupConfirmed: true,
1359+
}).save();
1360+
1361+
await coffeeChatService.reportStats();
1362+
1363+
const mockPostMessage =
1364+
jest.requireMock("../../src/slackbot").default.client.chat.postMessage;
1365+
1366+
const blocks: Array<{
1367+
type: string;
1368+
text?: { type: string; text: string };
1369+
}> = mockPostMessage.mock.calls[0][0].blocks;
1370+
1371+
const leaderboardBlock = blocks.find(
1372+
(b) =>
1373+
b.type === "section" &&
1374+
b.text?.text.includes("All-Time Meetup Leaderboard"),
1375+
);
1376+
1377+
expect(leaderboardBlock?.text?.text).not.toContain("<@U_OTHER>");
1378+
});
10771379
});
10781380

10791381
it("should not report stats if there is a previous pairing in a round long ago", async () => {

0 commit comments

Comments
 (0)