From 5fac8280302891d3175eb62a904fd1a77ad17004 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Mon, 20 Apr 2026 00:30:36 -0300 Subject: [PATCH] =?UTF-8?q?feat(bot):=20/birthday=20list=20=E2=80=94=20nex?= =?UTF-8?q?t=205=20upcoming=20birthdays?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a read-only subcommand to the existing /birthday command: - /birthday list — queries MemberBirthday for the guild, ranks by daysUntilBirthday(now, month, day), shows top 5 with relative label ("today", "tomorrow", "in N days") - Embed includes "Showing X of N" footer so large guilds know there's more - allowedMentions: { parse: [] } — never pings users when listing New helper: daysUntilBirthday(from, month, day) - Handles year rollover (past dates in this calendar year → next year) - Pure function, exported for direct testing Tests: 7 new cases (3 for daysUntilBirthday + 4 for the list subcommand) pushing the file to 15 passing total. Plays cleanly atop #740 (the scheduler + /birthday channel PR) since the only shared surface is the SlashCommandBuilder subcommand list — merges additively. --- .../general/commands/birthday.spec.ts | 104 +++++++++++++++++- .../functions/general/commands/birthday.ts | 82 ++++++++++++++ 2 files changed, 183 insertions(+), 3 deletions(-) diff --git a/packages/bot/src/functions/general/commands/birthday.spec.ts b/packages/bot/src/functions/general/commands/birthday.spec.ts index c2574674d..b599affea 100644 --- a/packages/bot/src/functions/general/commands/birthday.spec.ts +++ b/packages/bot/src/functions/general/commands/birthday.spec.ts @@ -3,12 +3,15 @@ import { describe, test, expect, jest, beforeEach } from '@jest/globals' jest.mock('@lucky/shared/utils', () => { const upsert = jest.fn() const deleteMany = jest.fn() + const findMany = jest.fn() return { infoLog: jest.fn(), debugLog: jest.fn(), errorLog: jest.fn(), - getPrismaClient: () => ({ memberBirthday: { upsert, deleteMany } }), - __mocks: { upsert, deleteMany }, + getPrismaClient: () => ({ + memberBirthday: { upsert, deleteMany, findMany }, + }), + __mocks: { upsert, deleteMany, findMany }, } }) @@ -23,6 +26,7 @@ const { __mocks: utilsMocks } = jest.requireMock('@lucky/shared/utils') as { deleteMany: jest.MockedFunction< (args: unknown) => Promise<{ count: number }> > + findMany: jest.MockedFunction<(args: unknown) => Promise> } } const { __mocks: replyMocks } = jest.requireMock( @@ -36,9 +40,10 @@ const { __mocks: replyMocks } = jest.requireMock( } const memberBirthdayUpsert = utilsMocks.upsert const memberBirthdayDeleteMany = utilsMocks.deleteMany +const memberBirthdayFindMany = utilsMocks.findMany const interactionReply = replyMocks.interactionReply -import birthdayCommand from './birthday.js' +import birthdayCommand, { daysUntilBirthday } from './birthday.js' function makeInteraction( subcommand: string, @@ -62,9 +67,28 @@ function makeInteraction( beforeEach(() => { memberBirthdayUpsert.mockReset().mockResolvedValue({}) memberBirthdayDeleteMany.mockReset().mockResolvedValue({ count: 1 }) + memberBirthdayFindMany.mockReset().mockResolvedValue([]) interactionReply.mockClear().mockResolvedValue(undefined) }) +describe('daysUntilBirthday', () => { + test('today returns 0', () => { + const d = new Date(Date.UTC(2026, 3, 20)) + expect(daysUntilBirthday(d, 4, 20)).toBe(0) + }) + test('tomorrow returns 1', () => { + const d = new Date(Date.UTC(2026, 3, 20)) + expect(daysUntilBirthday(d, 4, 21)).toBe(1) + }) + test('past date rolls to next year', () => { + const d = new Date(Date.UTC(2026, 3, 20)) + const days = daysUntilBirthday(d, 4, 19) + // April has 30 days, so 20 → next April 19 is 364 days + expect(days).toBeGreaterThan(300) + expect(days).toBeLessThan(366) + }) +}) + describe('/birthday', () => { test('rejects when used in DMs', async () => { await birthdayCommand.execute({ @@ -161,6 +185,80 @@ describe('/birthday', () => { expect(args.content.content).toContain('No birthday') }) + test('list shows empty state when no birthdays set', async () => { + memberBirthdayFindMany.mockResolvedValueOnce([]) + await birthdayCommand.execute({ + interaction: { + ...makeInteraction('list'), + guild: { id: 'guild-1', name: 'TestGuild' }, + } as never, + }) + const args = interactionReply.mock.calls[0][0] as { + content: { content: string } + } + expect(args.content.content).toContain('No birthdays') + }) + + test('list sorts by upcoming and caps at 5', async () => { + memberBirthdayFindMany.mockResolvedValueOnce([ + { userId: 'a', month: 12, day: 25 }, + { userId: 'b', month: 1, day: 1 }, + { userId: 'c', month: 7, day: 4 }, + { userId: 'd', month: 3, day: 15 }, + { userId: 'e', month: 6, day: 10 }, + { userId: 'f', month: 11, day: 11 }, + { userId: 'g', month: 2, day: 2 }, + ]) + await birthdayCommand.execute({ + interaction: { + ...makeInteraction('list'), + guild: { id: 'guild-1', name: 'TestGuild' }, + } as never, + }) + const args = interactionReply.mock.calls[0][0] as { + content: { embeds: Array<{ description: string; footer: { text: string } }> } + } + const description = args.content.embeds[0].description + // Should show exactly 5 bullet lines + expect(description.split('\n').length).toBe(5) + expect(args.content.embeds[0].footer.text).toContain('5 of 7') + }) + + test('list labels same-day match as "today"', async () => { + const now = new Date() + const todayMonth = now.getUTCMonth() + 1 + const todayDay = now.getUTCDate() + memberBirthdayFindMany.mockResolvedValueOnce([ + { userId: 'birthday-user', month: todayMonth, day: todayDay }, + ]) + await birthdayCommand.execute({ + interaction: { + ...makeInteraction('list'), + guild: { id: 'guild-1', name: 'TestGuild' }, + } as never, + }) + const args = interactionReply.mock.calls[0][0] as { + content: { embeds: Array<{ description: string }> } + } + expect(args.content.embeds[0].description).toContain('today') + }) + + test('list does not mention users in output (parse: [])', async () => { + memberBirthdayFindMany.mockResolvedValueOnce([ + { userId: 'u1', month: 3, day: 15 }, + ]) + await birthdayCommand.execute({ + interaction: { + ...makeInteraction('list'), + guild: { id: 'guild-1', name: 'TestGuild' }, + } as never, + }) + const args = interactionReply.mock.calls[0][0] as { + content: { allowedMentions: { parse: unknown[] } } + } + expect(args.content.allowedMentions).toEqual({ parse: [] }) + }) + test('handles upsert throwing gracefully', async () => { memberBirthdayUpsert.mockRejectedValueOnce(new Error('db down')) await birthdayCommand.execute({ diff --git a/packages/bot/src/functions/general/commands/birthday.ts b/packages/bot/src/functions/general/commands/birthday.ts index db7dfeb30..1ee38771a 100644 --- a/packages/bot/src/functions/general/commands/birthday.ts +++ b/packages/bot/src/functions/general/commands/birthday.ts @@ -25,6 +25,27 @@ function formatBirthday(month: number, day: number): string { return `${MONTHS[month - 1]} ${day}` } +// Days from `from` to the next occurrence of (month, day). 0 = today, 1 = +// tomorrow, ..., 365/366 on the same date next year. +export function daysUntilBirthday( + from: Date, + month: number, + day: number, +): number { + const year = from.getUTCFullYear() + const todayUtc = Date.UTC( + year, + from.getUTCMonth(), + from.getUTCDate(), + ) + let target = Date.UTC(year, month - 1, day) + if (target < todayUtc) { + target = Date.UTC(year + 1, month - 1, day) + } + const ms = target - todayUtc + return Math.round(ms / (24 * 60 * 60 * 1000)) +} + function validateDate( month: number, day: number, @@ -74,6 +95,13 @@ export default new Command({ sub .setName('clear') .setDescription('Remove your birthday from this server.'), + ) + .addSubcommand((sub) => + sub + .setName('list') + .setDescription( + 'Show the next upcoming birthdays in this server.', + ), ), category: 'general', execute: async ({ interaction }) => { @@ -142,6 +170,60 @@ export default new Command({ return } + if (subcommand === 'list') { + const rows = (await prisma.memberBirthday.findMany({ + where: { guildId: guild.id }, + select: { userId: true, month: true, day: true }, + })) as Array<{ userId: string; month: number; day: number }> + + if (rows.length === 0) { + await interactionReply({ + interaction, + content: { + content: + 'No birthdays set yet. Use `/birthday set` to add yours.', + }, + }) + return + } + + const now = new Date() + const annotated = rows + .map((r) => ({ + ...r, + daysUntil: daysUntilBirthday(now, r.month, r.day), + })) + .sort((a, b) => a.daysUntil - b.daysUntil) + .slice(0, 5) + + const lines = annotated.map((r) => { + const label = + r.daysUntil === 0 + ? '**today**' + : r.daysUntil === 1 + ? 'tomorrow' + : `in ${r.daysUntil} days` + return `• <@${r.userId}> — ${formatBirthday(r.month, r.day)} (${label})` + }) + + const embed = new EmbedBuilder() + .setTitle('🎂 Upcoming Birthdays') + .setDescription(lines.join('\n')) + .setColor(COLOR.LUCKY_PURPLE) + .setFooter({ + text: `Showing ${annotated.length} of ${rows.length} · ${guild.name}`, + }) + + await interactionReply({ + interaction, + content: { + embeds: [embed.toJSON()], + allowedMentions: { parse: [] }, + }, + }) + return + } + if (subcommand === 'clear') { const deleted = await prisma.memberBirthday.deleteMany({ where: {