diff --git a/src/app.ts b/src/app.ts index 16498967..a5b9c02b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,7 +5,7 @@ import { requestReview } from '@bot/requestReview'; import { triggerCron } from '@bot/triggerCron'; import { requestPosition } from '@bot/requestPosition'; import { requestPairingSession } from '@bot/requestPairingSession'; -import { acceptPairingSlot } from '@bot/acceptPairingSlot'; +import { pickPairingTimes } from '@bot/pickPairingTimes'; import { database } from '@database'; import { App, ExpressReceiver } from '@slack/bolt'; import log from '@utils/log'; @@ -39,7 +39,7 @@ export async function startApp(): Promise { declineReviewRequest.setup(app); requestPosition.setup(app); requestPairingSession.setup(app); - acceptPairingSlot.setup(app); + pickPairingTimes.setup(app); getReviewInfo.setup(app); // Schedule cron jobs diff --git a/src/bot/__tests__/acceptPairingSlot.test.ts b/src/bot/__tests__/acceptPairingSlot.test.ts deleted file mode 100644 index e6c016d0..00000000 --- a/src/bot/__tests__/acceptPairingSlot.test.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { acceptPairingSlot } from '../acceptPairingSlot'; -import { pairingSessionsRepo } from '@repos/pairingSessionsRepo'; -import { userRepo } from '@repos/userRepo'; -import { buildMockApp, buildMockActionParam } from '@utils/slackMocks'; -import { PairingSession } from '@models/PairingSession'; -import { InterviewFormat, InterviewType } from '@bot/enums'; -import * as PairingRequestService from '@/services/PairingRequestService'; -import * as PairingSessionCloserModule from '@/services/PairingSessionCloser'; -import { App } from '@slack/bolt'; -import { chatService } from '@/services/ChatService'; - -function makeInterview(overrides: Partial = {}): PairingSession { - return { - threadId: 'thread-1', - requestorId: 'r1', - candidateName: 'Dana', - languages: ['Python'], - format: InterviewFormat.REMOTE, - requestedAt: new Date(), - teammatesNeededCount: 2, - slots: [ - { - id: 'slot-1', - date: '2026-03-31', - startTime: '13:00', - endTime: '15:00', - interestedTeammates: [], - }, - ], - pendingTeammates: [{ userId: 'u1', expiresAt: 9999999999, messageTimestamp: 'ts-1' }], - declinedTeammates: [], - ...overrides, - }; -} - -describe('acceptPairingSlot', () => { - let app: App; - - beforeEach(() => { - app = buildMockApp(); - acceptPairingSlot.app = app; - pairingSessionsRepo.getByThreadIdOrUndefined = jest.fn().mockResolvedValue(makeInterview()); - userRepo.find = jest.fn().mockResolvedValue({ - id: 'u1', - name: 'Alice', - languages: ['Python'], - lastReviewedDate: undefined, - lastPairingReviewedDate: undefined, - interviewTypes: [InterviewType.PAIRING], - formats: [InterviewFormat.REMOTE], - }); - jest - .spyOn(PairingRequestService.pairingRequestService, 'recordSlotSelections') - .mockResolvedValue(makeInterview()); - jest - .spyOn(PairingSessionCloserModule.pairingSessionCloser, 'closeIfComplete') - .mockResolvedValue(false); - userRepo.markNowAsLastReviewedDate = jest.fn().mockResolvedValue(undefined); - chatService.updateDirectMessage = jest.fn().mockResolvedValue(undefined); - }); - - describe('handleSubmitSlots', () => { - it('should ack', async () => { - const param = buildMockActionParam(); - param.body.actions = [{ value: 'thread-1', action_id: 'pairing-submit-slots' } as any]; - param.body.user = { id: 'u1', name: 'Alice' } as any; - param.body.message = { ts: 'msg-ts-1' } as any; - param.body.state = { - values: { - 'pairing-dm-slots': { - 'pairing-slot-selections': { - selected_options: [{ value: 'slot-1' }], - }, - }, - }, - } as any; - - await acceptPairingSlot.handleSubmitSlots(param); - - expect(param.ack).toHaveBeenCalledTimes(1); - }); - - it('should call recordSlotSelections with the selected slot IDs', async () => { - const param = buildMockActionParam(); - param.body.actions = [{ value: 'thread-1', action_id: 'pairing-submit-slots' } as any]; - param.body.user = { id: 'u1', name: 'Alice' } as any; - param.body.message = { ts: 'msg-ts-1' } as any; - param.body.state = { - values: { - 'pairing-dm-slots': { - 'pairing-slot-selections': { - selected_options: [{ value: 'slot-1' }], - }, - }, - }, - } as any; - - await acceptPairingSlot.handleSubmitSlots(param); - - expect(PairingRequestService.pairingRequestService.recordSlotSelections).toHaveBeenCalledWith( - expect.anything(), - 'u1', - ['slot-1'], - [InterviewFormat.REMOTE], - ); - }); - - it('should not mark the user as last reviewed on submit — only on confirmed close', async () => { - const param = buildMockActionParam(); - param.body.actions = [{ value: 'thread-1', action_id: 'pairing-submit-slots' } as any]; - param.body.user = { id: 'u1', name: 'Alice' } as any; - param.body.message = { ts: 'msg-ts-1' } as any; - param.body.state = { - values: { - 'pairing-dm-slots': { - 'pairing-slot-selections': { selected_options: [{ value: 'slot-1' }] }, - }, - }, - } as any; - - await acceptPairingSlot.handleSubmitSlots(param); - - expect(userRepo.markNowAsLastReviewedDate).not.toHaveBeenCalled(); - }); - - it('should request the next teammate if the session is still active after accept', async () => { - const requestNextSpy = jest - .spyOn(PairingRequestService.pairingRequestService, 'requestNextTeammate') - .mockResolvedValue(undefined); - - const param = buildMockActionParam(); - param.body.actions = [{ value: 'thread-1', action_id: 'pairing-submit-slots' } as any]; - param.body.user = { id: 'u1', name: 'Alice' } as any; - param.body.message = { ts: 'msg-ts-1' } as any; - param.body.state = { - values: { - 'pairing-dm-slots': { - 'pairing-slot-selections': { selected_options: [{ value: 'slot-1' }] }, - }, - }, - } as any; - - await acceptPairingSlot.handleSubmitSlots(param); - - expect(requestNextSpy).toHaveBeenCalledWith(app, expect.anything()); - }); - - it('should not request the next teammate if the session was closed', async () => { - const requestNextSpy = jest - .spyOn(PairingRequestService.pairingRequestService, 'requestNextTeammate') - .mockResolvedValue(undefined); - jest - .spyOn(PairingSessionCloserModule.pairingSessionCloser, 'closeIfComplete') - .mockResolvedValue(true); - - const param = buildMockActionParam(); - param.body.actions = [{ value: 'thread-1', action_id: 'pairing-submit-slots' } as any]; - param.body.user = { id: 'u1', name: 'Alice' } as any; - param.body.message = { ts: 'msg-ts-1' } as any; - param.body.state = { - values: { - 'pairing-dm-slots': { - 'pairing-slot-selections': { selected_options: [{ value: 'slot-1' }] }, - }, - }, - } as any; - - await acceptPairingSlot.handleSubmitSlots(param); - - expect(requestNextSpy).not.toHaveBeenCalled(); - }); - - it('should call closeIfComplete after recording slot selections', async () => { - const param = buildMockActionParam(); - param.body.actions = [{ value: 'thread-1', action_id: 'pairing-submit-slots' } as any]; - param.body.user = { id: 'u1', name: 'Alice' } as any; - param.body.message = { ts: 'msg-ts-1' } as any; - param.body.state = { - values: { - 'pairing-dm-slots': { - 'pairing-slot-selections': { selected_options: [{ value: 'slot-1' }] }, - }, - }, - } as any; - - await acceptPairingSlot.handleSubmitSlots(param); - - expect(PairingSessionCloserModule.pairingSessionCloser.closeIfComplete).toHaveBeenCalledWith( - app, - 'thread-1', - ); - }); - - it('should update the DM after recording slot selections', async () => { - const param = buildMockActionParam(); - param.body.actions = [{ value: 'thread-1', action_id: 'pairing-submit-slots' } as any]; - param.body.user = { id: 'u1', name: 'Alice' } as any; - param.body.message = { ts: 'msg-ts-1' } as any; - param.body.state = { - values: { - 'pairing-dm-slots': { - 'pairing-slot-selections': { selected_options: [{ value: 'slot-1' }] }, - }, - }, - } as any; - - await acceptPairingSlot.handleSubmitSlots(param); - - expect(chatService.updateDirectMessage).toHaveBeenCalled(); - }); - - it('should call declineTeammate when no slots are selected', async () => { - jest - .spyOn(PairingRequestService.pairingRequestService, 'declineTeammate') - .mockResolvedValue(makeInterview()); - - const param = buildMockActionParam(); - param.body.actions = [{ value: 'thread-1', action_id: 'pairing-submit-slots' } as any]; - param.body.user = { id: 'u1', name: 'Alice' } as any; - param.body.message = { ts: 'msg-ts-1' } as any; - param.body.state = { - values: { - 'pairing-dm-slots': { - 'pairing-slot-selections': { selected_options: [] }, - }, - }, - } as any; - - await acceptPairingSlot.handleSubmitSlots(param); - - expect(PairingRequestService.pairingRequestService.declineTeammate).toHaveBeenCalledWith( - app, - expect.anything(), - 'u1', - expect.stringContaining("didn't select"), - ); - expect( - PairingRequestService.pairingRequestService.recordSlotSelections, - ).not.toHaveBeenCalled(); - }); - - it('should not record slot selections if user is not pending', async () => { - pairingSessionsRepo.getByThreadIdOrUndefined = jest - .fn() - .mockResolvedValue(makeInterview({ pendingTeammates: [] })); - - const param = buildMockActionParam(); - param.body.actions = [{ value: 'thread-1', action_id: 'pairing-submit-slots' } as any]; - param.body.user = { id: 'u1', name: 'Alice' } as any; - param.body.message = { ts: 'msg-ts-1' } as any; - param.body.state = { - values: { - 'pairing-dm-slots': { - 'pairing-slot-selections': { selected_options: [{ value: 'slot-1' }] }, - }, - }, - } as any; - - await acceptPairingSlot.handleSubmitSlots(param); - - expect( - PairingRequestService.pairingRequestService.recordSlotSelections, - ).not.toHaveBeenCalled(); - }); - }); - - describe('handleDeclineAll', () => { - it('should ack and call declineTeammate', async () => { - jest - .spyOn(PairingRequestService.pairingRequestService, 'declineTeammate') - .mockResolvedValue(makeInterview()); - const param = buildMockActionParam(); - param.body.actions = [{ value: 'thread-1', action_id: 'pairing-decline-all' } as any]; - param.body.user = { id: 'u1', name: 'Alice' } as any; - - await acceptPairingSlot.handleDeclineAll(param); - - expect(param.ack).toHaveBeenCalledTimes(1); - expect(PairingRequestService.pairingRequestService.declineTeammate).toHaveBeenCalled(); - }); - - it('should not call declineTeammate if user is not pending', async () => { - pairingSessionsRepo.getByThreadIdOrUndefined = jest - .fn() - .mockResolvedValue(makeInterview({ pendingTeammates: [] })); - jest - .spyOn(PairingRequestService.pairingRequestService, 'declineTeammate') - .mockResolvedValue(makeInterview()); - - const param = buildMockActionParam(); - param.body.actions = [{ value: 'thread-1', action_id: 'pairing-decline-all' } as any]; - param.body.user = { id: 'u1', name: 'Alice' } as any; - - await acceptPairingSlot.handleDeclineAll(param); - - expect(PairingRequestService.pairingRequestService.declineTeammate).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/src/bot/__tests__/getReviewInfo.test.ts b/src/bot/__tests__/getReviewInfo.test.ts index f6f9d3b4..77fda9f9 100644 --- a/src/bot/__tests__/getReviewInfo.test.ts +++ b/src/bot/__tests__/getReviewInfo.test.ts @@ -80,6 +80,7 @@ describe('getReviewInfo', () => { format: InterviewFormat.REMOTE, requestedAt: new Date(1650504468906), teammatesNeededCount: 2, + availabilityWindows: [], slots: [ { id: 'slot-1', diff --git a/src/bot/__tests__/pickPairingTimes.test.ts b/src/bot/__tests__/pickPairingTimes.test.ts new file mode 100644 index 00000000..03d37b89 --- /dev/null +++ b/src/bot/__tests__/pickPairingTimes.test.ts @@ -0,0 +1,360 @@ +import { pickPairingTimes } from '../pickPairingTimes'; +import { InterviewFormat } from '@bot/enums'; +import { PairingSession } from '@models/PairingSession'; +import { pairingSessionsRepo } from '@repos/pairingSessionsRepo'; +import { userRepo } from '@repos/userRepo'; +import { chatService } from '@/services/ChatService'; +import { pairingRequestService } from '@/services/PairingRequestService'; +import { pairingSessionCloser } from '@/services/PairingSessionCloser'; +import { buildMockApp, buildMockWebClient } from '@utils/slackMocks'; +import { slotsFromWindows } from '@utils/pairingSlots'; +import { parseMeta, serializeMeta, snapshotOf, timeToggleActionId } from '@utils/pairingPicker'; + +const THREAD_ID = 'thread-1'; +const DM_TS = 'dm-ts-1'; +const USER_ID = 'teammate-1'; + +// 13:00, 14:00 on the 31st; 08:00, 09:00 on the 1st. +const WINDOWS = [ + { date: '2026-03-31', startTime: '13:00', endTime: '17:00' }, + { date: '2026-04-01', startTime: '08:00', endTime: '12:00' }, +]; + +function makeSession(overrides: Partial = {}): PairingSession { + return { + threadId: THREAD_ID, + requestorId: 'recruiter-1', + candidateName: 'Dana', + languages: ['Python'], + format: InterviewFormat.REMOTE, + requestedAt: new Date('2026-03-30'), + teammatesNeededCount: 2, + availabilityWindows: WINDOWS, + slots: slotsFromWindows(WINDOWS), + pendingTeammates: [{ userId: USER_ID, expiresAt: Date.now() + 10000, messageTimestamp: DM_TS }], + declinedTeammates: [], + ...overrides, + }; +} + +/** Mimics what the real recordSlotSelections does to the session it returns. */ +function recordInto(session: PairingSession, indices: number[]): PairingSession { + return { + ...session, + slots: session.slots.map((slot, i) => + indices.includes(i) + ? { + ...slot, + interestedTeammates: [ + ...slot.interestedTeammates, + { userId: USER_ID, acceptedAt: 1, formats: [InterviewFormat.REMOTE] }, + ], + } + : slot, + ), + }; +} + +function metaFor(selected: number[]): string { + const session = makeSession(); + return serializeMeta({ + threadId: THREAD_ID, + dmTs: DM_TS, + candidateName: session.candidateName, + languages: session.languages, + format: session.format, + slots: snapshotOf(session), + selected, + }); +} + +function buttonParam(client = buildMockWebClient()) { + return { + client, + ack: jest.fn(), + body: { + user: { id: USER_ID }, + actions: [{ value: THREAD_ID }], + message: { ts: DM_TS }, + trigger_id: 'trigger-1', + } as any, + }; +} + +function toggleParam(selected: number[], index: number, client = buildMockWebClient()) { + return { + client, + ack: jest.fn(), + body: { + user: { id: USER_ID }, + actions: [{ action_id: timeToggleActionId(index) }], + view: { id: 'view-1', hash: 'hash-1', private_metadata: metaFor(selected) }, + } as any, + }; +} + +function submitParam(selected: number[], client = buildMockWebClient()) { + return { + client, + ack: jest.fn(), + body: { + user: { id: USER_ID }, + view: { private_metadata: metaFor(selected) }, + } as any, + }; +} + +function dmText(): string { + return (chatService.updateDirectMessage as jest.Mock).mock.calls[0][3][0].text.text; +} + +describe('pickPairingTimes', () => { + let session: PairingSession; + + beforeEach(() => { + pickPairingTimes.app = buildMockApp(); + session = makeSession(); + pairingSessionsRepo.getByThreadIdOrUndefined = jest.fn().mockResolvedValue(session); + userRepo.find = jest.fn().mockResolvedValue({ id: USER_ID, formats: [InterviewFormat.REMOTE] }); + chatService.updateDirectMessage = jest.fn().mockResolvedValue(undefined); + // reportErrorAndContinue posts through these — a test asserting we DIDN'T report needs them spied. + chatService.postBlocksMessage = jest.fn().mockResolvedValue({ ts: 'err-ts' }); + chatService.postInThread = jest.fn().mockResolvedValue(undefined); + pairingRequestService.slotsWithRoomFor = jest + .fn() + .mockImplementation((s: PairingSession, ids: string[]) => + s.slots.filter(slot => ids.includes(slot.id)), + ); + pairingRequestService.recordSlotSelections = jest + .fn() + .mockImplementation(async s => recordInto(s, [0])); + pairingRequestService.declineTeammate = jest.fn().mockResolvedValue(session); + pairingRequestService.requestNextTeammate = jest.fn().mockResolvedValue(undefined); + pairingSessionCloser.closeIfComplete = jest.fn().mockResolvedValue(false); + }); + + describe('openPicker', () => { + it('should open the picker with the session snapshotted and nothing selected', async () => { + const { client, ...param } = buttonParam(); + + await pickPairingTimes.openPicker({ client, ...param } as any); + + expect(param.ack).toHaveBeenCalledTimes(1); + const call = (client.views.open as jest.Mock).mock.calls[0][0]; + expect(call.trigger_id).toBe('trigger-1'); + const meta = parseMeta(call.view.private_metadata); + expect(meta).toMatchObject({ threadId: THREAD_ID, dmTs: DM_TS, selected: [] }); + expect(meta.slots).toHaveLength(4); + expect(meta.candidateName).toBe('Dana'); + }); + + it('should not open the picker once the session is gone, and collapse the DM', async () => { + pairingSessionsRepo.getByThreadIdOrUndefined = jest.fn().mockResolvedValue(undefined); + const { client, ...param } = buttonParam(); + + await pickPairingTimes.openPicker({ client, ...param } as any); + + expect(client.views.open).not.toHaveBeenCalled(); + expect(dmText()).toContain('filled by someone else'); + }); + + it('should not collapse the DM when the session is merely still being set up', async () => { + // The row is created before its DMs go out, so between the DM landing and pendingTeammates + // being written the teammate legitimately isn't on the list yet. Rewriting their DM here would + // destroy their buttons and claim an empty session was filled. + pairingSessionsRepo.getByThreadIdOrUndefined = jest + .fn() + .mockResolvedValue(makeSession({ pendingTeammates: [] })); + chatService.sendDirectMessage = jest.fn().mockResolvedValue(undefined); + const { client, ...param } = buttonParam(); + + await pickPairingTimes.openPicker({ client, ...param } as any); + + expect(client.views.open).not.toHaveBeenCalled(); + expect(chatService.updateDirectMessage).not.toHaveBeenCalled(); + expect(chatService.sendDirectMessage).toHaveBeenCalledWith( + client, + USER_ID, + expect.stringContaining('still being set up'), + ); + }); + + it('should not tell someone who already responded that the session was filled', async () => { + // They answered it — claiming someone else took it is untrue, and it may not even be filled. + const responded = makeSession({ pendingTeammates: [] }); + responded.declinedTeammates = [{ userId: USER_ID, declinedAt: 1 }]; + pairingSessionsRepo.getByThreadIdOrUndefined = jest.fn().mockResolvedValue(responded); + const { client, ...param } = buttonParam(); + + await pickPairingTimes.openPicker({ client, ...param } as any); + + expect(dmText()).toContain('already responded'); + expect(dmText()).not.toContain('filled by someone else'); + }); + }); + + describe('toggleTime', () => { + it('should select an unselected time and repaint against the current view hash', async () => { + const { client, ...param } = toggleParam([], 2); + + await pickPairingTimes.toggleTime({ client, ...param } as any); + + const call = (client.views.update as jest.Mock).mock.calls[0][0]; + expect(call.view_id).toBe('view-1'); + expect(call.hash).toBe('hash-1'); + expect(parseMeta(call.view.private_metadata).selected).toEqual([2]); + }); + + it('should deselect a time that was already picked', async () => { + const { client, ...param } = toggleParam([1, 2], 2); + + await pickPairingTimes.toggleTime({ client, ...param } as any); + + const call = (client.views.update as jest.Mock).mock.calls[0][0]; + expect(parseMeta(call.view.private_metadata).selected).toEqual([1]); + }); + + it('should not read the session — a sheet fetch per tap would burn the read quota', async () => { + const { client, ...param } = toggleParam([], 1); + + await pickPairingTimes.toggleTime({ client, ...param } as any); + + expect(pairingSessionsRepo.getByThreadIdOrUndefined).not.toHaveBeenCalled(); + }); + + it('should drop a repaint superseded by a newer view instead of reporting an error', async () => { + const conflicted = buildMockWebClient(); + conflicted.views.update = jest.fn().mockRejectedValue({ data: { error: 'hash_conflict' } }); + const { client, ...param } = toggleParam([], 1, conflicted); + + await pickPairingTimes.toggleTime({ client, ...param } as any); + + expect(chatService.postBlocksMessage).not.toHaveBeenCalled(); + }); + }); + + describe('submitTimes', () => { + it('should resolve picks by date and start time rather than by position', async () => { + // A session whose slots came back in a different order than the picker snapshotted them. + const reordered = makeSession({ slots: [...makeSession().slots].reverse() }); + pairingSessionsRepo.getByThreadIdOrUndefined = jest.fn().mockResolvedValue(reordered); + const { client, ...param } = submitParam([0]); // snapshot index 0 = Mar 31 13:00 + + await pickPairingTimes.submitTimes({ client, ...param } as any); + + const pickedIds = (pairingRequestService.recordSlotSelections as jest.Mock).mock.calls[0][2]; + const picked = reordered.slots.find(s => s.id === pickedIds[0]); + expect(picked).toMatchObject({ date: '2026-03-31', startTime: '13:00' }); + }); + + it('should record the picked slots and ask the next teammate when not yet complete', async () => { + const { client, ...param } = submitParam([0, 3]); + + await pickPairingTimes.submitTimes({ client, ...param } as any); + + expect(param.ack).toHaveBeenCalledTimes(1); + const [, userId, ids, formats] = (pairingRequestService.recordSlotSelections as jest.Mock) + .mock.calls[0]; + expect(userId).toBe(USER_ID); + expect(ids).toEqual([session.slots[0].id, session.slots[3].id]); + expect(formats).toEqual([InterviewFormat.REMOTE]); + expect(pairingRequestService.requestNextTeammate).toHaveBeenCalledTimes(1); + }); + + it('should not ask the next teammate once the session closes', async () => { + pairingSessionCloser.closeIfComplete = jest.fn().mockResolvedValue(true); + const { client, ...param } = submitParam([0]); + + await pickPairingTimes.submitTimes({ client, ...param } as any); + + expect(pairingRequestService.requestNextTeammate).not.toHaveBeenCalled(); + }); + + it('should confirm the picked times back to the teammate', async () => { + const { client, ...param } = submitParam([0]); + + await pickPairingTimes.submitTimes({ client, ...param } as any); + + expect(dmText()).toContain('Tue, Mar 31, 1 PM–4 PM'); + expect(dmText()).toContain('Dana'); + }); + + it('should confirm only the times actually recorded, not the ones that were already full', async () => { + // Picked 0 and 1, but slot 1 was already full, so only 0 landed. + const { client, ...param } = submitParam([0, 1]); + + await pickPairingTimes.submitTimes({ client, ...param } as any); + + expect(dmText()).toContain('Tue, Mar 31, 1 PM–4 PM'); + expect(dmText()).not.toContain('2 PM–5 PM'); + }); + + it('should DECLINE, not silently record nothing, when every picked time is already full', async () => { + // Otherwise recordSlotSelections drops them from pendingTeammates while adding them nowhere + // else, and nextInLineForPairing — which excludes only pending/declined/interested users — + // hands them this same session again, forever. + pairingRequestService.slotsWithRoomFor = jest.fn().mockReturnValue([]); + const { client, ...param } = submitParam([0, 1]); + + await pickPairingTimes.submitTimes({ client, ...param } as any); + + expect(pairingRequestService.recordSlotSelections).not.toHaveBeenCalled(); + expect(pairingRequestService.declineTeammate).toHaveBeenCalledWith( + pickPairingTimes.app, + session, + USER_ID, + expect.stringContaining('already covered'), + ); + }); + + it('should decline when the teammate submits without picking anything', async () => { + const { client, ...param } = submitParam([]); + + await pickPairingTimes.submitTimes({ client, ...param } as any); + + expect(pairingRequestService.declineTeammate).toHaveBeenCalledWith( + pickPairingTimes.app, + session, + USER_ID, + expect.stringContaining("didn't pick any times"), + ); + expect(pairingRequestService.recordSlotSelections).not.toHaveBeenCalled(); + }); + + it('should tell the teammate when the session filled while the picker was open', async () => { + pairingSessionsRepo.getByThreadIdOrUndefined = jest.fn().mockResolvedValue(undefined); + const { client, ...param } = submitParam([0]); + + await pickPairingTimes.submitTimes({ client, ...param } as any); + + expect(pairingRequestService.recordSlotSelections).not.toHaveBeenCalled(); + expect(dmText()).toContain('filled by someone else'); + }); + }); + + describe('declineAll', () => { + it('should decline the teammate', async () => { + const { client, ...param } = buttonParam(); + + await pickPairingTimes.declineAll({ client, ...param } as any); + + expect(pairingRequestService.declineTeammate).toHaveBeenCalledWith( + pickPairingTimes.app, + session, + USER_ID, + expect.stringContaining("You're all set"), + ); + }); + + it('should ignore a teammate who already responded', async () => { + const responded = makeSession({ pendingTeammates: [] }); + responded.declinedTeammates = [{ userId: USER_ID, declinedAt: 1 }]; + pairingSessionsRepo.getByThreadIdOrUndefined = jest.fn().mockResolvedValue(responded); + const { client, ...param } = buttonParam(); + + await pickPairingTimes.declineAll({ client, ...param } as any); + + expect(pairingRequestService.declineTeammate).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/bot/__tests__/requestPairingSession.test.ts b/src/bot/__tests__/requestPairingSession.test.ts index ab9704bb..3cdbfdd2 100644 --- a/src/bot/__tests__/requestPairingSession.test.ts +++ b/src/bot/__tests__/requestPairingSession.test.ts @@ -6,174 +6,169 @@ import { } from '@utils/slackMocks'; import { languageRepo } from '@repos/languageRepo'; import { pairingSessionsRepo } from '@repos/pairingSessionsRepo'; -import { ActionId, InterviewFormat } from '@bot/enums'; +import { InterviewFormat } from '@bot/enums'; import * as PairingQueueService from '@/services/PairingQueueService'; import * as PairingRequestService from '@/services/PairingRequestService'; import { chatService } from '@/services/ChatService'; const CHANNEL_ID = 'CHANNEL-123'; +interface WindowInput { + date: string | null; + start: string | null; + end: string | null; +} + +function stateValues(windows: WindowInput[]): Record { + const values: Record = { + 'candidate-name': { 'candidate-name': { value: 'Dana Smith' } }, + 'language-selections': { + 'language-selections': { selected_options: [{ value: 'Python' }] }, + }, + 'interview-format-selection': { + 'interview-format-selection': { + selected_option: { value: 'remote', text: { text: 'Remote' } }, + }, + }, + 'number-of-reviewers': { 'number-of-reviewers': { value: '2' } }, + }; + windows.forEach((window, i) => { + const n = i + 1; + values[`pairing-slot-${n}-date`] = { + [`pairing-slot-${n}-date`]: { selected_date: window.date }, + }; + values[`pairing-slot-${n}-start`] = { + [`pairing-slot-${n}-start`]: { selected_time: window.start }, + }; + values[`pairing-slot-${n}-end`] = { + [`pairing-slot-${n}-end`]: { selected_time: window.end }, + }; + }); + return values; +} + +function buildActionParam(windowCount: number, windows: WindowInput[]) { + const client = buildMockWebClient(); + return { + client, + param: { + ack: jest.fn(), + body: { + view: { + id: 'view-id-1', + private_metadata: JSON.stringify({ windowCount, languages: ['Python'] }), + state: { values: stateValues(windows) }, + }, + } as any, + client, + action: {} as any, + payload: {} as any, + respond: jest.fn(), + say: jest.fn(), + context: {} as any, + logger: {} as any, + next: jest.fn(), + }, + }; +} + +function buildCallback(windowCount: number, windows: WindowInput[]) { + return buildMockCallbackParam({ + body: { + user: { id: 'recruiter-1', name: 'Recruiter' }, + view: { + private_metadata: JSON.stringify({ windowCount, languages: ['Python'] }), + state: { values: stateValues(windows) }, + }, + } as any, + }); +} + describe('requestPairingSession', () => { beforeEach(() => { process.env.INTERVIEWING_CHANNEL_ID = CHANNEL_ID; process.env.NUMBER_OF_INITIAL_REVIEWERS = '5'; + jest.spyOn(PairingQueueService, 'getInitialUsersForPairingSession').mockResolvedValue([]); + jest + .spyOn(PairingRequestService.pairingRequestService, 'sendTeammateDM') + .mockResolvedValue('ts-1'); + pairingSessionsRepo.create = jest.fn().mockImplementation(async i => i); + pairingSessionsRepo.update = jest.fn().mockResolvedValue(undefined); + chatService.postTextMessage = jest.fn().mockResolvedValue({ ts: 'thread-ts-1' }); + chatService.sendDirectMessage = jest.fn().mockResolvedValue(undefined); }); describe('shortcut', () => { - it('should ack and open the modal with 1 initial slot', async () => { + it('should ack and open the modal with 1 initial window', async () => { const param = buildMockShortcutParam(); languageRepo.listAll = jest.fn().mockResolvedValueOnce(['Python', 'Java']); await requestPairingSession.shortcut(param); expect(param.ack).toHaveBeenCalledTimes(1); - expect(param.client.views.open).toHaveBeenCalledTimes(1); const view = (param.client.views.open as jest.Mock).mock.calls[0][0].view; expect(view.callback_id).toBe('submit-request-pairing'); expect(JSON.parse(view.private_metadata)).toEqual({ - slotCount: 1, + windowCount: 1, languages: ['Python', 'Java'], }); }); - it('should include an "Add another slot" button in the modal', async () => { + it('should include an "Add another day" button in the modal', async () => { const param = buildMockShortcutParam(); languageRepo.listAll = jest.fn().mockResolvedValueOnce(['Python']); await requestPairingSession.shortcut(param); const view = (param.client.views.open as jest.Mock).mock.calls[0][0].view; - const allActionIds = view.blocks + const actionIds = view.blocks .filter((b: any) => b.type === 'actions') .flatMap((b: any) => b.elements.map((e: any) => e.action_id)); - expect(allActionIds).toContain('add-pairing-slot'); + expect(actionIds).toContain('add-pairing-slot'); }); }); - describe('handleAddSlot', () => { - it('should call views.update with slotCount incremented by 1', async () => { - const client = buildMockWebClient(); - - const actionParam = { - ack: jest.fn(), - body: { - view: { - id: 'view-id-1', - private_metadata: JSON.stringify({ slotCount: 2, languages: ['Python'] }), - state: { - values: { - 'candidate-name': { 'candidate-name': { value: 'Dana' } }, - 'language-selections': { - 'language-selections': { selected_options: [{ value: 'Python' }] }, - }, - 'interview-format-selection': { - 'interview-format-selection': { - selected_option: { value: 'remote', text: { text: 'Remote' } }, - }, - }, - 'pairing-slot-1-date': { 'pairing-slot-1-date': { selected_date: '2026-03-31' } }, - 'pairing-slot-1-start': { 'pairing-slot-1-start': { selected_time: '13:00' } }, - 'pairing-slot-1-end': { 'pairing-slot-1-end': { selected_time: '15:00' } }, - 'pairing-slot-2-date': { 'pairing-slot-2-date': { selected_date: null } }, - 'pairing-slot-2-start': { 'pairing-slot-2-start': { selected_time: null } }, - 'pairing-slot-2-end': { 'pairing-slot-2-end': { selected_time: null } }, - }, - }, - }, - } as any, - client, - action: {} as any, - payload: {} as any, - respond: jest.fn(), - say: jest.fn(), - context: {} as any, - logger: {} as any, - next: jest.fn(), - }; + describe('handleAddWindow', () => { + it('should call views.update with the window count incremented', async () => { + const { client, param } = buildActionParam(2, [ + { date: '2026-03-31', start: '13:00', end: '17:00' }, + { date: null, start: null, end: null }, + ]); - await requestPairingSession.handleAddSlot(actionParam as any); + await requestPairingSession.handleAddWindow(param as any); - expect(actionParam.ack).toHaveBeenCalledTimes(1); - expect(client.views.update).toHaveBeenCalledTimes(1); - const updatedView = (client.views.update as jest.Mock).mock.calls[0][0].view; - expect(JSON.parse(updatedView.private_metadata)).toEqual( - expect.objectContaining({ slotCount: 3 }), + expect(param.ack).toHaveBeenCalledTimes(1); + const view = (client.views.update as jest.Mock).mock.calls[0][0].view; + expect(JSON.parse(view.private_metadata)).toEqual( + expect.objectContaining({ windowCount: 3 }), ); }); - it('should not exceed the slot cap', async () => { - const client = buildMockWebClient(); + it('should not exceed the window cap', async () => { + const windows = Array.from({ length: 7 }, () => ({ date: null, start: null, end: null })); + const { client, param } = buildActionParam(7, windows); - const stateValues: Record = { - 'candidate-name': { 'candidate-name': { value: 'Dana' } }, - 'language-selections': { 'language-selections': { selected_options: [] } }, - 'interview-format-selection': { - 'interview-format-selection': { - selected_option: { value: 'remote', text: { text: 'Remote' } }, - }, - }, - }; - for (let i = 1; i <= 20; i++) { - stateValues[`pairing-slot-${i}-date`] = { - [`pairing-slot-${i}-date`]: { selected_date: null }, - }; - stateValues[`pairing-slot-${i}-start`] = { - [`pairing-slot-${i}-start`]: { selected_time: null }, - }; - stateValues[`pairing-slot-${i}-end`] = { - [`pairing-slot-${i}-end`]: { selected_time: null }, - }; - } - - const actionParam = { - ack: jest.fn(), - body: { - view: { - id: 'view-id-1', - private_metadata: JSON.stringify({ slotCount: 20, languages: ['Python'] }), - state: { values: stateValues }, - }, - } as any, - client, - action: {} as any, - payload: {} as any, - respond: jest.fn(), - say: jest.fn(), - context: {} as any, - logger: {} as any, - next: jest.fn(), - }; + await requestPairingSession.handleAddWindow(param as any); - await requestPairingSession.handleAddSlot(actionParam as any); - - expect(actionParam.ack).toHaveBeenCalledTimes(1); + expect(param.ack).toHaveBeenCalledTimes(1); expect(client.views.update).not.toHaveBeenCalled(); }); - it('should re-populate existing slot values when re-rendering', async () => { + it('should read the legacy slotCount key from a modal opened before this deploy', async () => { + // Without normalizing, meta.windowCount is undefined -> Array.from({length: undefined}) is + // empty, so the recruiter's open form would silently re-render with zero window blocks. const client = buildMockWebClient(); - - const actionParam = { + const param = { ack: jest.fn(), body: { view: { id: 'view-id-1', - private_metadata: JSON.stringify({ slotCount: 1, languages: ['Python'] }), + private_metadata: JSON.stringify({ slotCount: 2, languages: ['Python'] }), state: { - values: { - 'candidate-name': { 'candidate-name': { value: 'Dana' } }, - 'language-selections': { - 'language-selections': { selected_options: [{ value: 'Python' }] }, - }, - 'interview-format-selection': { - 'interview-format-selection': { - selected_option: { value: 'remote', text: { text: 'Remote' } }, - }, - }, - 'pairing-slot-1-date': { 'pairing-slot-1-date': { selected_date: '2026-03-31' } }, - 'pairing-slot-1-start': { 'pairing-slot-1-start': { selected_time: '13:00' } }, - 'pairing-slot-1-end': { 'pairing-slot-1-end': { selected_time: '15:00' } }, - }, + values: stateValues([ + { date: '2026-03-31', start: '13:00', end: '17:00' }, + { date: null, start: null, end: null }, + ]), }, }, } as any, @@ -187,148 +182,120 @@ describe('requestPairingSession', () => { next: jest.fn(), }; - await requestPairingSession.handleAddSlot(actionParam as any); + await requestPairingSession.handleAddWindow(param as any); - const updatedView = (client.views.update as jest.Mock).mock.calls[0][0].view; - const slot1DateBlock = updatedView.blocks.find( - (b: any) => b.block_id === 'pairing-slot-1-date', + const view = (client.views.update as jest.Mock).mock.calls[0][0].view; + expect(JSON.parse(view.private_metadata)).toEqual( + expect.objectContaining({ windowCount: 3 }), ); - expect(slot1DateBlock?.element?.initial_date).toBe('2026-03-31'); + expect(view.blocks.some((b: any) => b.block_id === 'pairing-slot-3-date')).toBe(true); + }); + + it('should re-populate existing window values when re-rendering', async () => { + const { client, param } = buildActionParam(1, [ + { date: '2026-03-31', start: '13:00', end: '17:00' }, + ]); + + await requestPairingSession.handleAddWindow(param as any); + + const view = (client.views.update as jest.Mock).mock.calls[0][0].view; + const dateBlock = view.blocks.find((b: any) => b.block_id === 'pairing-slot-1-date'); + expect(dateBlock?.element?.initial_date).toBe('2026-03-31'); }); }); describe('callback', () => { - it('should ack, post to channel, DM teammates, and create the interview record', async () => { - const mockTeammate = { - id: 'teammate-1', - name: 'Alice', - languages: ['Python'], - lastReviewedDate: undefined, - lastPairingReviewedDate: undefined, - interviewTypes: ['pairing' as any], - formats: ['remote' as any], - }; - jest - .spyOn(PairingQueueService, 'getInitialUsersForPairingSession') - .mockResolvedValue([mockTeammate]); - jest - .spyOn(PairingRequestService.pairingRequestService, 'sendTeammateDM') - .mockResolvedValue('ts-1'); - pairingSessionsRepo.create = jest.fn().mockImplementation(async i => i); - pairingSessionsRepo.update = jest.fn().mockResolvedValue(undefined); - chatService.postTextMessage = jest.fn().mockResolvedValue({ ts: 'thread-ts-1' }); - - const callbackParam = buildMockCallbackParam({ - body: { - user: { id: 'recruiter-1', name: 'Recruiter' }, - view: { - private_metadata: JSON.stringify({ slotCount: 2, languages: ['Python'] }), - state: { - values: { - 'language-selections': { - 'language-selections': { selected_options: [{ value: 'Python' }] }, - }, - 'interview-format-selection': { - 'interview-format-selection': { selected_option: { value: 'remote' } }, - }, - 'candidate-name': { 'candidate-name': { value: 'Dana Smith' } }, - 'number-of-reviewers': { 'number-of-reviewers': { value: '2' } }, - 'pairing-slot-1-date': { 'pairing-slot-1-date': { selected_date: '2026-03-31' } }, - 'pairing-slot-1-start': { 'pairing-slot-1-start': { selected_time: '13:00' } }, - 'pairing-slot-1-end': { 'pairing-slot-1-end': { selected_time: '15:00' } }, - 'pairing-slot-2-date': { 'pairing-slot-2-date': { selected_date: '2026-04-01' } }, - 'pairing-slot-2-start': { 'pairing-slot-2-start': { selected_time: '09:00' } }, - 'pairing-slot-2-end': { 'pairing-slot-2-end': { selected_time: '11:00' } }, - }, - }, - }, - } as any, - }); + it('should slice each window into the bookable sessions inside it', async () => { + const param = buildCallback(2, [ + { date: '2026-03-31', start: '13:00', end: '17:00' }, + { date: '2026-04-01', start: '08:00', end: '12:00' }, + ]); + + await requestPairingSession.callback(param); + + expect(param.ack).toHaveBeenCalledWith(); + const session = (pairingSessionsRepo.create as jest.Mock).mock.calls[0][0]; + expect(session.candidateName).toBe('Dana Smith'); + expect(session.format).toBe(InterviewFormat.REMOTE); + expect(session.slots.map((s: any) => `${s.date} ${s.startTime}-${s.endTime}`)).toEqual([ + '2026-03-31 13:00-16:00', + '2026-03-31 14:00-17:00', + '2026-04-01 08:00-11:00', + '2026-04-01 09:00-12:00', + ]); + }); - await requestPairingSession.callback(callbackParam); + it('should reject a window too short for a session, before acking the modal', async () => { + const param = buildCallback(1, [{ date: '2026-03-31', start: '13:00', end: '15:00' }]); - expect(callbackParam.ack).toHaveBeenCalledTimes(1); - expect(pairingSessionsRepo.create).toHaveBeenCalledWith( - expect.objectContaining({ - candidateName: 'Dana Smith', - languages: ['Python'], - format: InterviewFormat.REMOTE, - slots: expect.arrayContaining([ - expect.objectContaining({ date: '2026-03-31', startTime: '13:00', endTime: '15:00' }), - expect.objectContaining({ date: '2026-04-01', startTime: '09:00', endTime: '11:00' }), - ]), - }), - ); + await requestPairingSession.callback(param); + + expect(param.ack).toHaveBeenCalledWith({ + response_action: 'errors', + errors: { + 'pairing-slot-1-end': "A 3 hour session doesn't fit — this window is only 2 hours.", + }, + }); + expect(pairingSessionsRepo.create).not.toHaveBeenCalled(); }); - it('should send an error DM when no valid slots are provided', async () => { - chatService.sendDirectMessage = jest.fn().mockResolvedValue(undefined); - pairingSessionsRepo.create = jest.fn(); + it('should reject an end time that is before the start time', async () => { + const param = buildCallback(1, [{ date: '2026-03-31', start: '17:00', end: '08:00' }]); - const callbackParam = buildMockCallbackParam({ - body: { - user: { id: 'recruiter-1', name: 'Recruiter' }, - view: { - private_metadata: JSON.stringify({ slotCount: 1, languages: ['Python'] }), - state: { - values: { - 'language-selections': { - 'language-selections': { selected_options: [{ value: 'Python' }] }, - }, - 'interview-format-selection': { - 'interview-format-selection': { selected_option: { value: 'remote' } }, - }, - 'candidate-name': { 'candidate-name': { value: 'Test' } }, - 'number-of-reviewers': { 'number-of-reviewers': { value: '2' } }, - 'pairing-slot-1-date': { 'pairing-slot-1-date': { selected_date: null } }, - 'pairing-slot-1-start': { 'pairing-slot-1-start': { selected_time: null } }, - 'pairing-slot-1-end': { 'pairing-slot-1-end': { selected_time: null } }, - }, - }, - }, - } as any, + await requestPairingSession.callback(param); + + expect(param.ack).toHaveBeenCalledWith({ + response_action: 'errors', + errors: { 'pairing-slot-1-end': 'End time must be after start time.' }, }); + expect(pairingSessionsRepo.create).not.toHaveBeenCalled(); + }); + + it('should report an error against the offending window when only one is bad', async () => { + const param = buildCallback(2, [ + { date: '2026-03-31', start: '08:00', end: '17:00' }, + { date: '2026-04-01', start: '08:00', end: '09:00' }, + ]); - await requestPairingSession.callback(callbackParam); + await requestPairingSession.callback(param); - expect(chatService.sendDirectMessage).toHaveBeenCalled(); + const ackArg = (param.ack as jest.Mock).mock.calls[0][0]; + expect(Object.keys(ackArg.errors)).toEqual(['pairing-slot-2-end']); expect(pairingSessionsRepo.create).not.toHaveBeenCalled(); }); - it('should read slotCount from private_metadata to parse the right number of slots', async () => { - jest.spyOn(PairingQueueService, 'getInitialUsersForPairingSession').mockResolvedValue([]); - pairingSessionsRepo.create = jest.fn().mockImplementation(async i => i); - chatService.postTextMessage = jest.fn().mockResolvedValue({ ts: 'thread-1' }); + it('should reject an empty form rather than silently dropping the window', async () => { + const param = buildCallback(1, [{ date: null, start: null, end: null }]); - const callbackParam = buildMockCallbackParam({ - body: { - user: { id: 'r1', name: 'R' }, - view: { - private_metadata: JSON.stringify({ slotCount: 1, languages: ['Python'] }), - state: { - values: { - 'language-selections': { - 'language-selections': { selected_options: [{ value: 'Python' }] }, - }, - 'interview-format-selection': { - 'interview-format-selection': { selected_option: { value: 'remote' } }, - }, - 'candidate-name': { 'candidate-name': { value: 'Test' } }, - 'number-of-reviewers': { 'number-of-reviewers': { value: '2' } }, - 'pairing-slot-1-date': { 'pairing-slot-1-date': { selected_date: '2026-03-31' } }, - 'pairing-slot-1-start': { 'pairing-slot-1-start': { selected_time: '09:00' } }, - 'pairing-slot-1-end': { 'pairing-slot-1-end': { selected_time: '11:00' } }, - }, - }, - }, - } as any, + await requestPairingSession.callback(param); + + expect(param.ack).toHaveBeenCalledWith({ + response_action: 'errors', + errors: { 'pairing-slot-1-end': 'Please provide at least one availability window.' }, }); + expect(pairingSessionsRepo.create).not.toHaveBeenCalled(); + }); + + it('should DM the initial teammates and record them as pending', async () => { + jest.spyOn(PairingQueueService, 'getInitialUsersForPairingSession').mockResolvedValue([ + { + id: 'teammate-1', + name: 'Alice', + languages: ['Python'], + lastReviewedDate: undefined, + lastPairingReviewedDate: undefined, + interviewTypes: ['pairing' as any], + formats: ['remote' as any], + }, + ]); + const param = buildCallback(1, [{ date: '2026-03-31', start: '08:00', end: '17:00' }]); - await requestPairingSession.callback(callbackParam); + await requestPairingSession.callback(param); - expect(pairingSessionsRepo.create).toHaveBeenCalledWith( + expect(PairingRequestService.pairingRequestService.sendTeammateDM).toHaveBeenCalledTimes(1); + expect(pairingSessionsRepo.update).toHaveBeenCalledWith( expect.objectContaining({ - slots: expect.arrayContaining([expect.objectContaining({ date: '2026-03-31' })]), + pendingTeammates: [expect.objectContaining({ userId: 'teammate-1' })], }), ); }); diff --git a/src/bot/acceptPairingSlot.ts b/src/bot/acceptPairingSlot.ts deleted file mode 100644 index 8f3df087..00000000 --- a/src/bot/acceptPairingSlot.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { ActionParam } from '@/slackTypes'; -import { App } from '@slack/bolt'; -import log from '@utils/log'; -import { ActionId, BlockId, InterviewFormatLabel } from './enums'; -import { userRepo } from '@repos/userRepo'; -import { pairingSessionsRepo } from '@repos/pairingSessionsRepo'; -import { pairingRequestService } from '@/services/PairingRequestService'; -import { pairingSessionCloser } from '@/services/PairingSessionCloser'; -import { reviewLockManager } from '@utils/reviewLockManager'; -import { lockedExecute } from '@utils/lockedExecute'; -import { reportErrorAndContinue } from '@utils/reportError'; -import { bold, compose, formatSlot, textBlock, ul } from '@utils/text'; -import { chatService } from '@/services/ChatService'; - -export const acceptPairingSlot = { - app: undefined as unknown as App, - - setup(app: App): void { - log.d('acceptPairingSlot.setup', 'Setting up acceptPairingSlot action handlers'); - this.app = app; - app.action(ActionId.PAIRING_SUBMIT_SLOTS, this.handleSubmitSlots.bind(this)); - app.action(ActionId.PAIRING_DECLINE_ALL, this.handleDeclineAll.bind(this)); - app.action(ActionId.PAIRING_SLOT_SELECTIONS, ({ ack }) => ack()); - }, - - async handleSubmitSlots({ ack, body, client }: ActionParam): Promise { - await ack(); - - try { - const userId = body.user.id; - const threadId = body.actions[0].value; - const messageTimestamp = body.message?.ts; - if (!threadId || !messageTimestamp) { - throw new Error('Missing threadId or messageTimestamp on pairing slot submit'); - } - - const selectedOptions: Array<{ value: string }> = - (body as any).state?.values?.[BlockId.PAIRING_DM_SLOTS]?.[ActionId.PAIRING_SLOT_SELECTIONS] - ?.selected_options ?? []; - const selectedSlotIds = selectedOptions.map(o => o.value); - - await lockedExecute(reviewLockManager.getLock(threadId), async () => { - const interview = await pairingSessionsRepo.getByThreadIdOrUndefined(threadId); - if (!interview) return; - - const isPending = interview.pendingTeammates.some(t => t.userId === userId); - if (!isPending) { - log.d('acceptPairingSlot', `User ${userId} already responded to ${threadId}`); - return; - } - - if (selectedSlotIds.length === 0) { - await pairingRequestService.declineTeammate( - acceptPairingSlot.app, - interview, - userId, - "You didn't select any available slots — we've moved on to the next person.", - ); - return; - } - - const user = await userRepo.find(userId); - const userFormats = user?.formats ?? []; - - const updatedInterview = await pairingRequestService.recordSlotSelections( - interview, - userId, - selectedSlotIds, - userFormats, - ); - - const selectedSlots = interview.slots.filter(s => selectedSlotIds.includes(s.id)); - const slotLines = selectedSlots.map(s => formatSlot(s.date, s.startTime, s.endTime)); - await chatService.updateDirectMessage(client, userId, messageTimestamp, [ - textBlock( - compose( - `*Thanks for your availability!* Here's what you submitted:`, - compose( - bold(`Candidate: ${interview.candidateName}`), - bold(`Languages: ${interview.languages.join(', ')}`), - bold(`Format: ${InterviewFormatLabel.get(interview.format) ?? interview.format}`), - ), - `*Your available slots:*\n${ul(...slotLines)}`, - `If your slot is selected, you'll be tagged in #interviewing to coordinate scheduling. If not, you'll stay at the top of the queue for the next interview.`, - ), - ), - ]); - - const closed = await pairingSessionCloser.closeIfComplete(acceptPairingSlot.app, threadId); - if (!closed) { - await pairingRequestService.requestNextTeammate(acceptPairingSlot.app, updatedInterview); - } - }); - } catch (err: any) { - await reportErrorAndContinue(acceptPairingSlot.app, 'Error handling pairing slot submit', { - body, - })(err as Error); - } - }, - - async handleDeclineAll({ ack, body }: ActionParam): Promise { - await ack(); - - try { - const userId = body.user.id; - const threadId = body.actions[0].value; - if (!threadId) throw new Error('Missing threadId on pairing decline'); - - await lockedExecute(reviewLockManager.getLock(threadId), async () => { - const interview = await pairingSessionsRepo.getByThreadIdOrUndefined(threadId); - if (!interview) return; - - const isPending = interview.pendingTeammates.some(t => t.userId === userId); - if (!isPending) { - log.d('acceptPairingSlot.handleDeclineAll', `User ${userId} already responded`); - return; - } - - await pairingRequestService.declineTeammate( - acceptPairingSlot.app, - interview, - userId, - "You're all set — we've moved to the next person.", - ); - }); - } catch (err: any) { - await reportErrorAndContinue(acceptPairingSlot.app, 'Error handling pairing decline', { - body, - })(err as Error); - } - }, -}; diff --git a/src/bot/enums.ts b/src/bot/enums.ts index d22b680e..e03e48e2 100644 --- a/src/bot/enums.ts +++ b/src/bot/enums.ts @@ -13,6 +13,8 @@ export const enum Interaction { SHORTCUT_REQUEST_PAIRING = 'shortcut-request-pairing', SUBMIT_REQUEST_PAIRING = 'submit-request-pairing', + + SUBMIT_PAIRING_TIMES = 'submit-pairing-times', } export const enum ActionId { @@ -29,8 +31,8 @@ export const enum ActionId { INTERVIEW_FORMAT_SELECTION = 'interview-format-selection', CANDIDATE_NAME = 'candidate-name', ADD_PAIRING_SLOT = 'add-pairing-slot', - PAIRING_SLOT_SELECTIONS = 'pairing-slot-selections', - PAIRING_SUBMIT_SLOTS = 'pairing-submit-slots', + PAIRING_OPEN_PICKER = 'pairing-open-picker', + PAIRING_TOGGLE_TIME = 'pairing-toggle-time', PAIRING_DECLINE_ALL = 'pairing-decline-all', LEAVE_QUEUE = 'leave-queue', } @@ -41,6 +43,7 @@ export const enum BlockId { PAIRING_DM_CONTEXT = 'pairing-dm-context', PAIRING_DM_SLOTS = 'pairing-dm-slots', PAIRING_DM_ACTIONS = 'pairing-dm-actions', + PAIRING_PICKER_SUMMARY = 'pairing-picker-summary', } export const enum Deadline { @@ -92,3 +95,8 @@ export const InterviewFormatLabel = new Map([ [InterviewFormat.IN_PERSON, 'In-Person'], [InterviewFormat.HYBRID, 'Hybrid'], ]); + +/** Falls back to the raw value so an unlabelled format renders as itself rather than "undefined". */ +export function formatLabel(format: InterviewFormat): string { + return InterviewFormatLabel.get(format) ?? format; +} diff --git a/src/bot/getReviewInfo.ts b/src/bot/getReviewInfo.ts index 6d6374f3..d1ef2cec 100644 --- a/src/bot/getReviewInfo.ts +++ b/src/bot/getReviewInfo.ts @@ -3,7 +3,8 @@ import { userRepo } from '@repos/userRepo'; import { App } from '@slack/bolt'; import { KnownBlock, View } from '@slack/types'; import log from '@utils/log'; -import { bold, formatSlot, mention, ul } from '@utils/text'; +import { bold, formatDate, formatSlot, formatTime, mention, textBlock, ul } from '@utils/text'; +import { groupByDate } from '@utils/pairingSlots'; import { InterviewFormatLabel, Interaction } from './enums'; import { activeReviewRepo } from '@repos/activeReviewsRepo'; import { ActiveReview } from '@models/ActiveReview'; @@ -77,28 +78,27 @@ export const getReviewInfo = { }, }, { type: 'divider' }, - { - type: 'section', - text: { - type: 'mrkdwn', - text: bold( - `Candidate availability (${session.slots.length} slot${session.slots.length !== 1 ? 's' : ''}):`, - ), - }, - }, - ...session.slots.map(slot => ({ - type: 'section', - text: { - type: 'mrkdwn', - text: ul( - `${formatSlot(slot.date, slot.startTime, slot.endTime)} — ${slot.interestedTeammates.length} interested${ - slot.interestedTeammates.length > 0 - ? `: ${slot.interestedTeammates.map(t => mention({ id: t.userId })).join(', ')}` - : '' - }`, - ), - }, - })), + textBlock( + bold( + `Candidate availability (${session.slots.length} session${session.slots.length !== 1 ? 's' : ''}):`, + ), + ), + // One block per day, not per session — a week of business-hours windows slices into ~60 + // sessions, and a block each would make this modal unreadable and crowd Slack's 100 block cap. + ...groupByDate(session.slots).map(([date, slots]) => + textBlock( + `${bold(formatDate(date))}\n${ul( + ...slots.map(({ item: slot }) => { + const interested = slot.interestedTeammates; + const who = + interested.length > 0 + ? `: ${interested.map(t => mention({ id: t.userId })).join(', ')}` + : ''; + return `${formatTime(slot.startTime)}–${formatTime(slot.endTime)} — ${interested.length} interested${who}`; + }), + )}`, + ), + ), ]; return { title: { text: 'Pairing Session Info', type: 'plain_text' }, diff --git a/src/bot/pickPairingTimes.ts b/src/bot/pickPairingTimes.ts new file mode 100644 index 00000000..784060ab --- /dev/null +++ b/src/bot/pickPairingTimes.ts @@ -0,0 +1,300 @@ +import { ActionParam, CallbackParam, WebClient } from '@/slackTypes'; +import { chatService } from '@/services/ChatService'; +import { pairingRequestService } from '@/services/PairingRequestService'; +import { pairingSessionCloser } from '@/services/PairingSessionCloser'; +import { PairingSession, PairingSlot } from '@models/PairingSession'; +import { pairingSessionsRepo } from '@repos/pairingSessionsRepo'; +import { userRepo } from '@repos/userRepo'; +import { App } from '@slack/bolt'; +import { lockedExecute } from '@utils/lockedExecute'; +import log from '@utils/log'; +import { + chipIndexFrom, + parseMeta, + PickerMeta, + pickerView, + snapshotOf, + TIME_TOGGLE_PATTERN, + toggleSelection, +} from '@utils/pairingPicker'; +import { pairingRequestBuilder } from '@utils/PairingRequestBuilder'; +import { reportErrorAndContinue } from '@utils/reportError'; +import { reviewLockManager } from '@utils/reviewLockManager'; +import { compose, formatSlot, textBlock, ul } from '@utils/text'; +import { ActionId, Interaction } from './enums'; + +const ALREADY_FILLED = + "This session was filled by someone else — nothing to do. You're still at the top of the queue for the next one."; + +const ALREADY_COVERED = + "*Every time you picked was already covered* by other teammates, so we haven't put you down for anything. You'll stay at the top of the queue for the next interview."; + +const STILL_SETTING_UP = + "This request is still being set up — give it a few seconds and tap again. (Nothing's lost; your buttons are still above.)"; + +const ALREADY_RESPONDED = + "You've already responded to this request — nothing more to do. We'll tag you in #interviewing if one of your times is picked."; + +type TeammateStanding = 'pending' | 'responded' | 'gone' | 'setting-up'; + +/** + * A teammate who isn't pending isn't necessarily done with us. + * + * The session row is created before its DMs go out, so between the DM landing and pendingTeammates + * being written there's a window where the teammate legitimately isn't on the list yet. Collapsing + * their DM in that window would destroy the buttons and tell them the session was filled when it's + * empty — so the ambiguous case gets its own answer rather than being lumped in with 'gone'. + * + * A finished session is removed from the sheet outright, which is what makes 'gone' distinguishable. + */ +function standingOf(session: PairingSession | undefined, userId: string): TeammateStanding { + if (!session) return 'gone'; + if (session.pendingTeammates.some(t => t.userId === userId)) return 'pending'; + + const responded = + session.declinedTeammates.some(t => t.userId === userId) || + session.slots.some(s => s.interestedTeammates.some(t => t.userId === userId)); + return responded ? 'responded' : 'setting-up'; +} + +export const pickPairingTimes = { + app: undefined as unknown as App, + + setup(app: App): void { + log.d('pickPairingTimes.setup', 'Setting up pairing time picker handlers'); + this.app = app; + app.action(ActionId.PAIRING_OPEN_PICKER, this.openPicker.bind(this)); + app.action(TIME_TOGGLE_PATTERN, this.toggleTime.bind(this)); + app.action(ActionId.PAIRING_DECLINE_ALL, this.declineAll.bind(this)); + app.view(Interaction.SUBMIT_PAIRING_TIMES, this.submitTimes.bind(this)); + }, + + async openPicker({ ack, body, client }: ActionParam): Promise { + await ack(); + + try { + const userId = body.user.id; + const threadId = body.actions[0].value; + const dmTs = body.message?.ts; + if (!threadId || !dmTs) { + throw new Error('Missing threadId or message timestamp when opening the pairing picker'); + } + + const session = await pairingSessionsRepo.getByThreadIdOrUndefined(threadId); + const standing = standingOf(session, userId); + if (standing !== 'pending') { + await reportStanding(client, userId, dmTs, standing); + return; + } + + const meta: PickerMeta = { + threadId, + dmTs, + candidateName: session!.candidateName, + languages: session!.languages, + format: session!.format, + slots: snapshotOf(session!), + selected: [], + }; + await client.views.open({ trigger_id: body.trigger_id, view: pickerView(meta) }); + } catch (err: any) { + await reportErrorAndContinue(pickPairingTimes.app, 'Error opening the pairing picker', { + body, + })(err as Error); + } + }, + + /** + * A repaint is a pure function of the metadata the picker already carries — no database read, so + * a fast clicker can't burn the Sheets quota, and no reliance on button styling to say what's + * selected. + */ + async toggleTime({ ack, body, client }: ActionParam): Promise { + await ack(); + + try { + const view = body.view; + if (!view) throw new Error('Missing view on pairing time toggle'); + + const index = chipIndexFrom(body.actions[0].action_id); + if (index == null) { + throw new Error(`Unrecognized pairing chip: ${body.actions[0].action_id}`); + } + + const meta = parseMeta(view.private_metadata); + const selected = toggleSelection(meta.selected, index); + + await client.views.update({ + view_id: view.id, + hash: view.hash, + view: pickerView({ ...meta, selected }), + }); + } catch (err: any) { + // Tapping several chips in quick succession puts overlapping updates in flight, and Slack + // rejects the ones built from a superseded view. That's the hash guard doing its job — the + // user's next tap repaints from the current view — not something to page anyone about. + if (err?.data?.error === 'hash_conflict') { + log.d('pickPairingTimes.toggleTime', 'Superseded by a newer view; dropping this repaint'); + return; + } + await reportErrorAndContinue(pickPairingTimes.app, 'Error toggling a pairing time', { + body, + })(err as Error); + } + }, + + async submitTimes({ ack, body, client }: CallbackParam): Promise { + await ack(); + + try { + const userId = body.user.id; + const meta = parseMeta(body.view.private_metadata); + if (!meta.threadId || !meta.dmTs) { + throw new Error('Missing threadId or message timestamp on pairing time submit'); + } + + if (meta.selected.length === 0) { + await decline( + userId, + meta.threadId, + "You didn't pick any times — we've moved on to the next person.", + ); + return; + } + + await lockedExecute(reviewLockManager.getLock(meta.threadId), async () => { + const session = await pairingSessionsRepo.getByThreadIdOrUndefined(meta.threadId); + const standing = standingOf(session, userId); + if (standing !== 'pending') { + await reportStanding(client, userId, meta.dmTs, standing); + return; + } + + // Resolve against what the picker showed, not by position — a slot's identity is its date + // and start time, and matching on those survives any future reordering of session.slots. + const picked = new Set( + meta.selected.map(i => `${meta.slots[i]?.date}T${meta.slots[i]?.startTime}`), + ); + const pickedIds = session!.slots + .filter(slot => picked.has(`${slot.date}T${slot.startTime}`)) + .map(slot => slot.id); + + const user = await userRepo.find(userId); + const userFormats = user?.formats ?? []; + + // Every slot they picked has since filled up. Recording would drop them from pendingTeammates + // while adding them nowhere else, and nextInLineForPairing would then re-offer them this same + // session on a loop — so decline them, which is what actually happened. + const withRoom = pairingRequestService.slotsWithRoomFor(session!, pickedIds, userFormats); + if (withRoom.length === 0) { + await pairingRequestService.declineTeammate( + pickPairingTimes.app, + session!, + userId, + ALREADY_COVERED, + ); + return; + } + + const updated = await pairingRequestService.recordSlotSelections( + session!, + userId, + pickedIds, + userFormats, + ); + + // recordSlotSelections skips slots that already have enough teammates, so report what + // actually landed — otherwise someone holds a time the session has no record of. + const recorded = updated.slots.filter(slot => + slot.interestedTeammates.some(t => t.userId === userId), + ); + await confirmToTeammate(client, userId, meta.dmTs, updated, recorded); + + const closed = await pairingSessionCloser.closeIfComplete( + pickPairingTimes.app, + meta.threadId, + ); + if (!closed) { + await pairingRequestService.requestNextTeammate(pickPairingTimes.app, updated); + } + }); + } catch (err: any) { + await reportErrorAndContinue(pickPairingTimes.app, 'Error submitting pairing times', { + body, + })(err as Error); + } + }, + + async declineAll({ ack, body }: ActionParam): Promise { + await ack(); + + try { + const threadId = body.actions[0].value; + if (!threadId) throw new Error('Missing threadId on pairing decline'); + + await decline(body.user.id, threadId, "You're all set — we've moved to the next person."); + } catch (err: any) { + await reportErrorAndContinue(pickPairingTimes.app, 'Error handling pairing decline', { + body, + })(err as Error); + } + }, +}; + +/** + * Tells a teammate why we're not taking their answer. + * + * 'setting-up' deliberately posts a NEW message instead of rewriting the DM: their buttons are still + * valid and will work in a moment, so collapsing the DM would strand them. + * + * 'responded' and 'gone' get different copy on purpose — telling someone who already answered that + * the session "was filled by someone else" is simply untrue, and it isn't even known to be filled. + */ +async function reportStanding( + client: WebClient, + userId: string, + dmTs: string, + standing: TeammateStanding, +): Promise { + if (standing === 'setting-up') { + log.d('pickPairingTimes', `Session not ready for ${userId} yet; leaving their DM intact`); + await chatService.sendDirectMessage(client, userId, STILL_SETTING_UP); + return; + } + + const message = standing === 'responded' ? ALREADY_RESPONDED : ALREADY_FILLED; + await chatService.updateDirectMessage(client, userId, dmTs, [textBlock(message)]); +} + +/** Both ways of passing — the decline button, and submitting with nothing picked — land here. */ +async function decline(userId: string, threadId: string, message: string): Promise { + await lockedExecute(reviewLockManager.getLock(threadId), async () => { + const session = await pairingSessionsRepo.getByThreadIdOrUndefined(threadId); + if (standingOf(session, userId) !== 'pending') { + log.d('pickPairingTimes.decline', `User ${userId} can't decline ${threadId} right now`); + return; + } + await pairingRequestService.declineTeammate(pickPairingTimes.app, session!, userId, message); + }); +} + +async function confirmToTeammate( + client: WebClient, + userId: string, + dmTs: string, + session: PairingSession, + recorded: PairingSlot[], +): Promise { + await chatService.updateDirectMessage(client, userId, dmTs, [ + textBlock( + compose( + `*Thanks for your availability!* Here's what you submitted:`, + pairingRequestBuilder.sessionHeader(session), + `*Your available times:*\n${ul( + ...recorded.map(s => formatSlot(s.date, s.startTime, s.endTime)), + )}`, + `If one of your times is picked, you'll be tagged in #interviewing to coordinate scheduling. If not, you'll stay at the top of the queue for the next interview.`, + ), + ), + ]); +} diff --git a/src/bot/requestPairingSession.ts b/src/bot/requestPairingSession.ts index 8d58845a..1f39d2b6 100644 --- a/src/bot/requestPairingSession.ts +++ b/src/bot/requestPairingSession.ts @@ -11,29 +11,79 @@ import { chatService } from '@/services/ChatService'; import { getInitialUsersForPairingSession } from '@/services/PairingQueueService'; import { pairingRequestService } from '@/services/PairingRequestService'; import { determineExpirationTime } from '@utils/reviewExpirationUtils'; -import { PairingSession, PairingSlot, PendingPairingTeammate } from '@models/PairingSession'; +import { + AvailabilityWindow, + PairingSession, + PairingSlot, + PendingPairingTeammate, +} from '@models/PairingSession'; +import { + MAX_SESSIONS, + PAIRING_SESSION_HOURS, + slotsFromWindows, + validateWindow, +} from '@utils/pairingSlots'; -const MAX_SLOTS = 20; +/** + * Each window becomes several bookable sessions, so this is a cap on days offered, not on slots. + * Seven business-hours days stays comfortably inside Slack's block and button limits on the picker. + */ +const MAX_WINDOWS = 7; interface ModalMeta { - slotCount: number; + windowCount: number; languages: string[]; } -interface SlotState { - date?: string | null; - startTime?: string | null; - endTime?: string | null; -} +type WindowState = { [K in keyof AvailabilityWindow]?: AvailabilityWindow[K] | null }; interface ModalState { candidateName?: string; selectedLanguageOptions?: Option[]; formatOption?: { value: string; text: { type: 'plain_text'; text: string } }; - slots: SlotState[]; + windows: WindowState[]; +} + +/** + * A modal opened before this deploy carries `{slotCount}` rather than `{windowCount}`, and an + * unrecognised count would silently render a form with zero window blocks (Array.from({length: + * undefined}) is empty) or add NaN windows. Normalise once, here, so no caller has to care. + */ +function readModalMeta(view: { private_metadata?: string } | undefined): ModalMeta { + const raw = JSON.parse(view?.private_metadata || '{}'); + const count = Number(raw.windowCount ?? raw.slotCount); + return { + windowCount: Number.isInteger(count) ? Math.min(Math.max(count, 1), MAX_WINDOWS) : 1, + languages: Array.isArray(raw.languages) ? raw.languages : [], + }; +} + +/** + * The single source of these ids. Block ids double as action ids, and the renderer and the reader + * sit 300 lines apart — if they ever built the strings independently and drifted, readWindows would + * come back undefined and the recruiter's day would vanish from the form with no error. + */ +function windowBlockIds(windowNumber: number): { date: string; start: string; end: string } { + return { + date: `pairing-slot-${windowNumber}-date`, + start: `pairing-slot-${windowNumber}-start`, + end: `pairing-slot-${windowNumber}-end`, + }; +} + +function readWindows(body: any, windowCount: number): WindowState[] { + const v = body.view.state.values; + return Array.from({ length: windowCount }, (_, i) => { + const ids = windowBlockIds(i + 1); + return { + date: v[ids.date]?.[ids.date]?.selected_date, + startTime: v[ids.start]?.[ids.start]?.selected_time, + endTime: v[ids.end]?.[ids.end]?.selected_time, + }; + }); } -function readStateFromBody(body: any, slotCount: number): ModalState { +function readStateFromBody(body: any, windowCount: number): ModalState { const v = body.view.state.values; return { candidateName: v['candidate-name']?.['candidate-name']?.value, @@ -46,14 +96,33 @@ function readStateFromBody(body: any, slotCount: number): ModalState { text: { type: 'plain_text' as const, text: opt.text?.text ?? '' }, }; })(), - slots: Array.from({ length: slotCount }, (_, i) => ({ - date: v[`pairing-slot-${i + 1}-date`]?.[`pairing-slot-${i + 1}-date`]?.selected_date, - startTime: v[`pairing-slot-${i + 1}-start`]?.[`pairing-slot-${i + 1}-start`]?.selected_time, - endTime: v[`pairing-slot-${i + 1}-end`]?.[`pairing-slot-${i + 1}-end`]?.selected_time, - })), + windows: readWindows(body, windowCount), }; } +/** + * Slack can't constrain one timepicker against another, so an unbookable window can only be caught + * on submit. Errors are keyed to the end-time block so they render under the field that's wrong. + */ +export function validateWindows(windows: WindowState[]): Record { + const errors: Record = {}; + windows.forEach((window, i) => { + if (!window.date || !window.startTime || !window.endTime) return; + const error = validateWindow(window.startTime, window.endTime); + if (error) errors[windowBlockIds(i + 1).end] = error; + }); + return errors; +} + +/** Drops windows the recruiter left blank; validateWindows has already rejected the unbookable ones. */ +export function toAvailabilityWindows(windows: WindowState[]): AvailabilityWindow[] { + return windows.flatMap(window => + window.date && window.startTime && window.endTime + ? [{ date: window.date, startTime: window.startTime, endTime: window.endTime }] + : [], + ); +} + export const requestPairingSession = { app: undefined as unknown as App, @@ -62,11 +131,11 @@ export const requestPairingSession = { this.app = app; app.shortcut(Interaction.SHORTCUT_REQUEST_PAIRING, this.shortcut.bind(this)); app.view(Interaction.SUBMIT_REQUEST_PAIRING, this.callback.bind(this)); - app.action(ActionId.ADD_PAIRING_SLOT, this.handleAddSlot.bind(this)); + app.action(ActionId.ADD_PAIRING_SLOT, this.handleAddWindow.bind(this)); }, - dialog(languages: string[], slotCount: number, currentState?: ModalState): View { - const meta: ModalMeta = { slotCount, languages }; + dialog(languages: string[], windowCount: number, currentState?: ModalState): View { + const meta: ModalMeta = { windowCount, languages }; const blocks: (Block | KnownBlock)[] = [ { type: 'input', @@ -129,22 +198,25 @@ export const requestPairingSession = { type: 'section', text: { type: 'mrkdwn', - text: `*Candidate availability (${slotCount} slot${slotCount !== 1 ? 's' : ''}):*`, + text: compose( + '*When is the candidate available?*', + `Enter the full window they gave you. We'll offer teammates every *${PAIRING_SESSION_HOURS} hour* session that fits inside it, so a window of 8 AM–5 PM becomes starts at 8, 9, 10, 11, 12, 1, and 2.`, + ), }, }, - ...Array.from({ length: slotCount }, (_, i) => - buildSlotBlocks(i + 1, currentState?.slots[i]), + ...Array.from({ length: windowCount }, (_, i) => + buildWindowBlocks(i + 1, currentState?.windows[i]), ).flat(), ]; - if (slotCount < MAX_SLOTS) { + if (windowCount < MAX_WINDOWS) { blocks.push({ type: 'actions', elements: [ { type: 'button', action_id: ActionId.ADD_PAIRING_SLOT, - text: { type: 'plain_text', text: '+ Add another slot' }, + text: { type: 'plain_text', text: '+ Add another day' }, }, ], } as Block); @@ -178,47 +250,81 @@ export const requestPairingSession = { } }, - async handleAddSlot({ ack, body, client }: ActionParam): Promise { + async handleAddWindow({ ack, body, client }: ActionParam): Promise { await ack(); const view = (body as any).view; - const meta: ModalMeta = JSON.parse(view?.private_metadata || '{"slotCount":1,"languages":[]}'); - if (meta.slotCount >= MAX_SLOTS) { - log.d('requestPairingSession.handleAddSlot', 'Already at max slots'); + const meta = readModalMeta(view); + if (meta.windowCount >= MAX_WINDOWS) { + log.d('requestPairingSession.handleAddWindow', 'Already at max windows'); return; } - const newSlotCount = meta.slotCount + 1; - const currentState = readStateFromBody(body, meta.slotCount); + const currentState = readStateFromBody(body, meta.windowCount); await client.views.update({ view_id: view.id, - view: this.dialog(meta.languages, newSlotCount, currentState), + view: this.dialog(meta.languages, meta.windowCount + 1, currentState), }); }, async callback({ ack, client, body }: CallbackParam): Promise { - await ack(); const user = body.user; + + // Validation has to happen before the ack — once the modal is acked it's gone, and + // response_action:'errors' has nothing left to attach to. + let slots: PairingSlot[]; + let availabilityWindows: AvailabilityWindow[]; + let meta: ModalMeta; try { - const meta: ModalMeta = JSON.parse( - (body as any).view.private_metadata || '{"slotCount":1,"languages":[]}', + meta = readModalMeta((body as any).view); + const windows = readWindows(body, meta.windowCount); + const errors = validateWindows(windows); + if (Object.keys(errors).length > 0) { + await ack({ response_action: 'errors', errors }); + return; + } + availabilityWindows = toAvailabilityWindows(windows); + slots = slotsFromWindows(availabilityWindows); + if (slots.length === 0) { + await ack({ + response_action: 'errors', + errors: { [windowBlockIds(1).end]: 'Please provide at least one availability window.' }, + }); + return; + } + // The picker carries every slot in Slack's private_metadata. Past the budget the modal simply + // won't open, and the teammate's button would fail with nothing to explain it — so the + // recruiter finds out here, in the form, where they can narrow the windows. + if (slots.length > MAX_SESSIONS) { + await ack({ + response_action: 'errors', + errors: { + [windowBlockIds(meta.windowCount).end]: + `That's ${slots.length} possible start times — too many to offer at once. Narrow the windows to ${MAX_SESSIONS} or fewer.`, + }, + }); + return; + } + } catch (err: any) { + log.e('requestPairingSession.callback', 'Failed to validate', err); + await ack(); + await chatService.sendDirectMessage( + client, + user.id, + compose('Something went wrong :/', codeBlock(err.message)), ); + return; + } + + await ack(); + + try { const candidateName = blockUtils.getBlockValue(body, ActionId.CANDIDATE_NAME).value as string; const languages = blockUtils.getLanguageFromBody(body); const format = blockUtils.getBlockValue(body, ActionId.INTERVIEW_FORMAT_SELECTION) .selected_option.value as InterviewFormat; - const slots = parseSlots(body, meta.slotCount); const teammatesNeededCount = Number( blockUtils.getBlockValue(body, ActionId.NUMBER_OF_REVIEWERS).value, ); - if (slots.length === 0) { - await chatService.sendDirectMessage( - client, - user.id, - 'Please provide at least one availability slot.', - ); - return; - } - const channel = process.env.INTERVIEWING_CHANNEL_ID; const numberOfInitialReviewers = Number(process.env.NUMBER_OF_INITIAL_REVIEWERS); @@ -248,21 +354,27 @@ export const requestPairingSession = { format, requestedAt: new Date(), teammatesNeededCount, + availabilityWindows, slots, declinedTeammates: [], pendingTeammates: [], }; await pairingSessionsRepo.create(interview); - const pendingTeammates: PendingPairingTeammate[] = []; - for (const teammate of teammates) { - const ts = await pairingRequestService.sendTeammateDM(this.app, teammate.id, interview); - pendingTeammates.push({ + // Each DM is two serialized Slack calls (open the conversation, then post), so sending them + // one teammate at a time made the recruiter wait on 2N round trips. Promise.all preserves + // order, so pendingTeammates still lines up with the queue. + const pendingTeammates: PendingPairingTeammate[] = await Promise.all( + teammates.map(async teammate => ({ userId: teammate.id, expiresAt: determineExpirationTime(new Date()), - messageTimestamp: ts, - }); - } + messageTimestamp: await pairingRequestService.sendTeammateDM( + this.app, + teammate.id, + interview, + ), + })), + ); if (pendingTeammates.length > 0) { await pairingSessionsRepo.update({ ...interview, pendingTeammates }); @@ -278,56 +390,38 @@ export const requestPairingSession = { }, }; -function buildSlotBlocks(slotNumber: number, state?: SlotState): (Block | KnownBlock)[] { - const dateId = `pairing-slot-${slotNumber}-date`; - const startId = `pairing-slot-${slotNumber}-start`; - const endId = `pairing-slot-${slotNumber}-end`; +function buildWindowBlocks(windowNumber: number, state?: WindowState): (Block | KnownBlock)[] { + const ids = windowBlockIds(windowNumber); return [ { type: 'input', - block_id: dateId, - label: { type: 'plain_text', text: `Slot ${slotNumber}: Date` }, + block_id: ids.date, + label: { type: 'plain_text', text: `Day ${windowNumber}: Date` }, element: { type: 'datepicker', - action_id: dateId, + action_id: ids.date, ...(state?.date ? { initial_date: state.date } : {}), }, }, { type: 'input', - block_id: startId, - label: { type: 'plain_text', text: `Slot ${slotNumber}: Start time` }, + block_id: ids.start, + label: { type: 'plain_text', text: `Day ${windowNumber}: Available from` }, element: { type: 'timepicker', - action_id: startId, + action_id: ids.start, initial_time: state?.startTime ?? '08:00', }, }, { type: 'input', - block_id: endId, - label: { type: 'plain_text', text: `Slot ${slotNumber}: End time` }, + block_id: ids.end, + label: { type: 'plain_text', text: `Day ${windowNumber}: Available until` }, element: { type: 'timepicker', - action_id: endId, + action_id: ids.end, initial_time: state?.endTime ?? '17:00', }, }, ]; } - -function parseSlots(body: any, slotCount: number): PairingSlot[] { - const slots: PairingSlot[] = []; - for (let n = 1; n <= slotCount; n++) { - const date = - body.view.state.values[`pairing-slot-${n}-date`]?.[`pairing-slot-${n}-date`]?.selected_date; - const startTime = - body.view.state.values[`pairing-slot-${n}-start`]?.[`pairing-slot-${n}-start`]?.selected_time; - const endTime = - body.view.state.values[`pairing-slot-${n}-end`]?.[`pairing-slot-${n}-end`]?.selected_time; - if (date && startTime && endTime) { - slots.push({ id: crypto.randomUUID(), date, startTime, endTime, interestedTeammates: [] }); - } - } - return slots; -} diff --git a/src/cron/__tests__/expirePairingRequests.test.ts b/src/cron/__tests__/expirePairingRequests.test.ts index aea8fba3..df974185 100644 --- a/src/cron/__tests__/expirePairingRequests.test.ts +++ b/src/cron/__tests__/expirePairingRequests.test.ts @@ -18,6 +18,7 @@ function makeInterview(pendingTeammates: PendingPairingTeammate[]): PairingSessi format: InterviewFormat.REMOTE, requestedAt: new Date(), teammatesNeededCount: 2, + availabilityWindows: [], slots: [], pendingTeammates, declinedTeammates: [], diff --git a/src/database/models/PairingSession.ts b/src/database/models/PairingSession.ts index 52b02703..7464a56b 100644 --- a/src/database/models/PairingSession.ts +++ b/src/database/models/PairingSession.ts @@ -6,6 +6,9 @@ export interface PairingSession { candidateName: string; languages: string[]; format: InterviewFormat; + /** What the candidate actually told the recruiter. */ + availabilityWindows: AvailabilityWindow[]; + /** The bookable sessions sliced out of those windows — what teammates pick from. */ slots: PairingSlot[]; requestedAt: Date; teammatesNeededCount: number; @@ -13,6 +16,19 @@ export interface PairingSession { declinedTeammates: DeclinedPairingTeammate[]; } +/** + * Kept alongside the slots it produced. Slicing is lossy — two windows on one day collapse into a + * single span if you try to infer them back from the slots — so the windows are stored, not derived. + */ +export interface AvailabilityWindow { + /** ISO date string: YYYY-MM-DD */ + date: string; + /** 24h time: HH:MM */ + startTime: string; + /** 24h time: HH:MM */ + endTime: string; +} + export interface PairingSlot { /** Unique ID for this slot (crypto.randomUUID()) */ id: string; diff --git a/src/database/repos/__tests__/pairingSessionsRepo.test.ts b/src/database/repos/__tests__/pairingSessionsRepo.test.ts index 3e70f6eb..0723f959 100644 --- a/src/database/repos/__tests__/pairingSessionsRepo.test.ts +++ b/src/database/repos/__tests__/pairingSessionsRepo.test.ts @@ -24,6 +24,7 @@ function buildPairingSession(overrides: Partial = {}): PairingSe format: InterviewFormat.REMOTE, requestedAt: new Date(1000000000000), teammatesNeededCount: 2, + availabilityWindows: [], slots: [ { id: 'slot-1', @@ -40,6 +41,33 @@ function buildPairingSession(overrides: Partial = {}): PairingSe } describe('pairingSessionsRepo', () => { + describe('availabilityWindows column', () => { + it('should be the last column so adding it cannot shift any existing one', () => { + // openSheet calls setHeaderRow positionally on every open. Inserting a column mid-list would + // relabel every column after it and misalign the data in rows already on the sheet. + const columns = pairingSessionsRepo.columns; + expect(columns[columns.length - 1]).toBe('availabilityWindows'); + }); + + it('should read a row written before the column existed as having no windows', () => { + const row = createMockRow({ + threadId: 'thread-1', + requestorId: 'recruiter-1', + candidateName: 'Dana', + languages: 'Python', + format: InterviewFormat.REMOTE, + requestedAt: '1000000000000', + teammatesNeededCount: '2', + slots: '[]', + pendingTeammates: '[]', + declinedTeammates: '[]', + // availabilityWindows absent, exactly as an older row would be. + }); + + expect(mapRowToPairingSession(row).availabilityWindows).toEqual([]); + }); + }); + describe('mapRowToPairingSession', () => { it('should deserialize all fields correctly', () => { const interview = buildPairingSession(); diff --git a/src/database/repos/pairingSessionsRepo.ts b/src/database/repos/pairingSessionsRepo.ts index ad7c358a..aa208991 100644 --- a/src/database/repos/pairingSessionsRepo.ts +++ b/src/database/repos/pairingSessionsRepo.ts @@ -16,6 +16,9 @@ enum Column { SLOTS = 'slots', PENDING_TEAMMATES = 'pendingTeammates', DECLINED_TEAMMATES = 'declinedTeammates', + // Appended, never inserted: openSheet rewrites the header row positionally, so adding a column + // anywhere but the end relabels every column after it and misaligns existing rows. + AVAILABILITY_WINDOWS = 'availabilityWindows', } export function mapRowToPairingSession(row: GoogleSpreadsheetRow): PairingSession { @@ -27,6 +30,8 @@ export function mapRowToPairingSession(row: GoogleSpreadsheetRow): PairingSessio format: row.get(Column.FORMAT) as InterviewFormat, requestedAt: new Date(Number(row.get(Column.REQUESTED_AT))), teammatesNeededCount: Number(row.get(Column.TEAMMATES_NEEDED_COUNT)), + // Sessions written before this column existed have no value here. + availabilityWindows: JSON.parse(row.get(Column.AVAILABILITY_WINDOWS) || '[]'), slots: JSON.parse(row.get(Column.SLOTS)), pendingTeammates: JSON.parse(row.get(Column.PENDING_TEAMMATES)), declinedTeammates: JSON.parse(row.get(Column.DECLINED_TEAMMATES)), @@ -42,6 +47,7 @@ function mapPairingSessionToRow(interview: PairingSession): Record [Column.FORMAT]: interview.format, [Column.REQUESTED_AT]: interview.requestedAt.getTime(), [Column.TEAMMATES_NEEDED_COUNT]: interview.teammatesNeededCount, + [Column.AVAILABILITY_WINDOWS]: JSON.stringify(interview.availabilityWindows), [Column.SLOTS]: JSON.stringify(interview.slots), [Column.PENDING_TEAMMATES]: JSON.stringify(interview.pendingTeammates), [Column.DECLINED_TEAMMATES]: JSON.stringify(interview.declinedTeammates), diff --git a/src/services/PairingRequestService.ts b/src/services/PairingRequestService.ts index 10afddf3..015a8d5e 100644 --- a/src/services/PairingRequestService.ts +++ b/src/services/PairingRequestService.ts @@ -32,6 +32,26 @@ function slotNeedsTeammate( } export const pairingRequestService = { + /** + * Which of the slots a teammate picked would actually take them. + * + * A slot that already has enough teammates silently rejects further ones. If that's true of every + * slot they picked, recording would drop them from pendingTeammates without adding them anywhere + * else — and nextInLineForPairing, which excludes only pending/declined/interested users, would + * hand them the very same session again, forever. Callers must decline instead. + */ + slotsWithRoomFor( + interview: PairingSession, + selectedSlotIds: string[], + userFormats: InterviewFormat[], + ): PairingSlot[] { + return interview.slots + .filter(slot => selectedSlotIds.includes(slot.id)) + .filter(slot => + slotNeedsTeammate(slot, interview.format, interview.teammatesNeededCount, userFormats), + ); + }, + /** * Record which slots a teammate selected and remove them from pending. * Does NOT check close conditions — call pairingSessionCloser.closeIfComplete after. @@ -116,15 +136,7 @@ export const pairingRequestService = { async sendTeammateDM(app: App, userId: string, interview: PairingSession): Promise { const dmId = await chatService.getDirectMessageId(app.client, userId); - const payload = pairingRequestBuilder.buildTeammateDM( - dmId, - { id: interview.requestorId }, - interview.candidateName, - interview.languages, - interview.format, - interview.slots, - interview.threadId, - ); + const payload = pairingRequestBuilder.buildTeammateDM(dmId, interview); const message = await app.client.chat.postMessage({ ...payload, token: process.env.SLACK_BOT_TOKEN, diff --git a/src/services/__tests__/PairingQueueService.test.ts b/src/services/__tests__/PairingQueueService.test.ts index db80e852..77f5fe06 100644 --- a/src/services/__tests__/PairingQueueService.test.ts +++ b/src/services/__tests__/PairingQueueService.test.ts @@ -31,6 +31,7 @@ function makePairingSession(overrides: Partial = {}): PairingSes format: InterviewFormat.REMOTE, requestedAt: new Date(), teammatesNeededCount: 2, + availabilityWindows: [], slots: [], pendingTeammates: [], declinedTeammates: [], diff --git a/src/services/__tests__/PairingRequestService.test.ts b/src/services/__tests__/PairingRequestService.test.ts index 3cd17da8..cab0e7b3 100644 --- a/src/services/__tests__/PairingRequestService.test.ts +++ b/src/services/__tests__/PairingRequestService.test.ts @@ -2,7 +2,7 @@ import { pairingSessionsRepo } from '@repos/pairingSessionsRepo'; import { chatService } from '@/services/ChatService'; import { pairingRequestService } from '../PairingRequestService'; import { buildMockApp } from '@utils/slackMocks'; -import { PairingSession, PairingSlot } from '@models/PairingSession'; +import { PairingSession } from '@models/PairingSession'; import { InterviewFormat } from '@bot/enums'; import { App } from '@slack/bolt'; import * as PairingQueueService from '../PairingQueueService'; @@ -16,6 +16,7 @@ function makeInterview(overrides: Partial = {}): PairingSession format: InterviewFormat.REMOTE, requestedAt: new Date(), teammatesNeededCount: 2, + availabilityWindows: [], slots: [ { id: 'slot-1', diff --git a/src/services/__tests__/PairingSessionCloser.test.ts b/src/services/__tests__/PairingSessionCloser.test.ts index 092ad58a..ab36ce1b 100644 --- a/src/services/__tests__/PairingSessionCloser.test.ts +++ b/src/services/__tests__/PairingSessionCloser.test.ts @@ -28,6 +28,7 @@ function makeInterview(overrides: Partial = {}): PairingSession format: InterviewFormat.REMOTE, requestedAt: new Date(), teammatesNeededCount: 2, + availabilityWindows: [], slots: [], pendingTeammates: [], declinedTeammates: [], diff --git a/src/utils/PairingRequestBuilder.ts b/src/utils/PairingRequestBuilder.ts index 8e8fd4c1..05200163 100644 --- a/src/utils/PairingRequestBuilder.ts +++ b/src/utils/PairingRequestBuilder.ts @@ -1,50 +1,49 @@ -import { ActionId, BlockId, InterviewFormat, InterviewFormatLabel } from '@bot/enums'; -import { PairingSlot } from '@models/PairingSession'; -import { compose, formatSlot, mention } from '@utils/text'; +import { ActionId, BlockId, formatLabel } from '@bot/enums'; +import { PairingSession } from '@models/PairingSession'; +import { bold, compose, formatSlot, mention, textBlock, ul } from '@utils/text'; +import { PAIRING_SESSION_HOURS } from '@utils/pairingSlots'; import { Block } from '@slack/types'; -function formatSlotLabel(slot: PairingSlot): string { - return formatSlot(slot.date, slot.startTime, slot.endTime); -} - export const pairingRequestBuilder = { - buildTeammateDMBlocks( - requestor: { id: string }, - candidateName: string, - languages: string[], - format: InterviewFormat, - slots: PairingSlot[], - threadId: string, - ): Block[] { + /** The candidate/languages/format header, shared by the DM, the picker, and the confirmation. */ + sessionHeader(session: PairingSession): string { + return compose( + bold(`Candidate: ${session.candidateName}`), + bold(`Languages: ${session.languages.join(', ')}`), + bold(`Format: ${formatLabel(session.format)}`), + ); + }, + + buildTeammateDMBlocks(session: PairingSession): Block[] { return [ { - block_id: BlockId.PAIRING_DM_CONTEXT, - type: 'section', - text: { - type: 'mrkdwn', - text: compose( - `${mention(requestor)} needs a teammate for a pairing session.`, - `*Candidate:* ${candidateName}`, - `*Languages:* ${languages.join(', ')}`, - `*Format:* ${InterviewFormatLabel.get(format) ?? format}`, + ...textBlock( + compose( + `${mention({ id: session.requestorId })} needs a teammate for a pairing session.`, + this.sessionHeader(session), ), - }, + ), + block_id: BlockId.PAIRING_DM_CONTEXT, } as Block, { + ...textBlock( + compose( + `Sessions run *${PAIRING_SESSION_HOURS} hours*. ${session.candidateName} is available:`, + // Only a session written before the availabilityWindows column existed can be empty + // here; a new one can't get past validation without a window. Such a session's slots are + // also un-sliced, so its picker is wrong too — this keeps the DM from rendering a blank + // paragraph, it does not make an old session correct. Drain the sheet before deploying. + session.availabilityWindows.length > 0 + ? ul( + ...session.availabilityWindows.map(w => + formatSlot(w.date, w.startTime, w.endTime), + ), + ) + : '_No availability was recorded for this session._', + 'Pick the start times that work for you — whatever you pick, we book.', + ), + ), block_id: BlockId.PAIRING_DM_SLOTS, - type: 'section', - text: { - type: 'mrkdwn', - text: 'Check all slots you are available for:', - }, - accessory: { - type: 'checkboxes', - action_id: ActionId.PAIRING_SLOT_SELECTIONS, - options: slots.map(slot => ({ - text: { type: 'plain_text' as const, text: formatSlotLabel(slot) }, - value: slot.id, - })), - }, } as Block, { block_id: BlockId.PAIRING_DM_ACTIONS, @@ -52,43 +51,28 @@ export const pairingRequestBuilder = { elements: [ { type: 'button', - action_id: ActionId.PAIRING_SUBMIT_SLOTS, - text: { type: 'plain_text', text: 'Submit availability' }, + action_id: ActionId.PAIRING_OPEN_PICKER, + text: { type: 'plain_text', text: 'Pick your times' }, style: 'primary', - value: threadId, + value: session.threadId, }, { type: 'button', action_id: ActionId.PAIRING_DECLINE_ALL, text: { type: 'plain_text', text: 'None of these' }, style: 'danger', - value: threadId, + value: session.threadId, }, ], } as Block, ]; }, - buildTeammateDM( - teammateId: string, - requestor: { id: string }, - candidateName: string, - languages: string[], - format: InterviewFormat, - slots: PairingSlot[], - threadId: string, - ) { + buildTeammateDM(teammateId: string, session: PairingSession) { return { channel: teammateId, - text: `Pairing session requested for ${candidateName}`, - blocks: this.buildTeammateDMBlocks( - requestor, - candidateName, - languages, - format, - slots, - threadId, - ), + text: `Pairing session requested for ${session.candidateName}`, + blocks: this.buildTeammateDMBlocks(session), }; }, }; diff --git a/src/utils/__tests__/PairingRequestBuilder.test.ts b/src/utils/__tests__/PairingRequestBuilder.test.ts index d730d623..5d3b261c 100644 --- a/src/utils/__tests__/PairingRequestBuilder.test.ts +++ b/src/utils/__tests__/PairingRequestBuilder.test.ts @@ -1,90 +1,109 @@ import { pairingRequestBuilder } from '../PairingRequestBuilder'; -import { InterviewFormat, ActionId, BlockId } from '@bot/enums'; -import { PairingSlot } from '@models/PairingSession'; +import { InterviewFormat } from '@bot/enums'; +import { AvailabilityWindow, PairingSession } from '@models/PairingSession'; +import { slotsFromWindows } from '../pairingSlots'; -function makeSlot(overrides: Partial = {}): PairingSlot { +const WINDOWS: AvailabilityWindow[] = [ + { date: '2026-03-31', startTime: '13:00', endTime: '17:00' }, + { date: '2026-04-01', startTime: '08:00', endTime: '12:00' }, +]; + +function makeSession(availabilityWindows: AvailabilityWindow[] = WINDOWS): PairingSession { return { - id: 'slot-abc', - date: '2026-03-31', - startTime: '13:00', - endTime: '15:00', - interestedTeammates: [], - ...overrides, + threadId: 'thread-1', + requestorId: 'recruiter-1', + candidateName: 'Dana', + languages: ['Python'], + format: InterviewFormat.REMOTE, + requestedAt: new Date('2026-03-30'), + teammatesNeededCount: 2, + availabilityWindows, + slots: slotsFromWindows(availabilityWindows), + pendingTeammates: [], + declinedTeammates: [], }; } +function blockText(session: PairingSession, blockId: string): string { + const block = pairingRequestBuilder + .buildTeammateDMBlocks(session) + .find(b => b.block_id === blockId) as any; + return block.text.text; +} + describe('pairingRequestBuilder', () => { - describe('buildTeammateDM', () => { - const slots = [makeSlot(), makeSlot({ id: 'slot-def', date: '2026-04-01' })]; + describe('sessionHeader', () => { + it('should render the candidate, languages, and format', () => { + const header = pairingRequestBuilder.sessionHeader(makeSession()); + + expect(header).toContain('Candidate: Dana'); + expect(header).toContain('Languages: Python'); + expect(header).toContain('Format: Remote'); + }); + }); + describe('buildTeammateDMBlocks', () => { it('should include a context block with candidate info', () => { - const result = pairingRequestBuilder.buildTeammateDMBlocks( - { id: 'recruiter-1' }, - 'Dana', - ['Python'], - InterviewFormat.REMOTE, - slots, - 'thread-1', - ); + const text = blockText(makeSession(), 'pairing-dm-context'); - const contextBlock = result.find(b => b.block_id === 'pairing-dm-context'); - expect(contextBlock).toBeDefined(); - expect((contextBlock as any).text.text).toContain('Dana'); - expect((contextBlock as any).text.text).toContain('Python'); + expect(text).toContain('Dana'); + expect(text).toContain('Python'); }); - it('should include a checkboxes block with one option per slot', () => { - const result = pairingRequestBuilder.buildTeammateDMBlocks( - { id: 'recruiter-1' }, - 'Dana', - ['Python'], - InterviewFormat.REMOTE, - slots, - 'thread-1', - ); + it('should show the windows the recruiter entered rather than listing every session', () => { + const text = blockText(makeSession(), 'pairing-dm-slots'); + + expect(text).toContain('Tue, Mar 31, 1 PM–5 PM'); + expect(text).toContain('Wed, Apr 1, 8 AM–12 PM'); + }); - const slotsBlock = result.find(b => b.block_id === 'pairing-dm-slots') as any; - expect(slotsBlock).toBeDefined(); - expect(slotsBlock.accessory.type).toBe('checkboxes'); - expect(slotsBlock.accessory.options).toHaveLength(2); - expect(slotsBlock.accessory.options[0].value).toBe('slot-abc'); - expect(slotsBlock.accessory.options[0].text.text).toBe('Tue, Mar 31, 1 PM–3 PM'); - expect(slotsBlock.accessory.options[1].value).toBe('slot-def'); + it('should tell the teammate how long a session runs', () => { + expect(blockText(makeSession(), 'pairing-dm-slots')).toContain('*3 hours*'); }); - it('should include a submit button and a decline-all button in the actions block', () => { - const result = pairingRequestBuilder.buildTeammateDMBlocks( - { id: 'recruiter-1' }, - 'Dana', - ['Python'], - InterviewFormat.REMOTE, - slots, - 'thread-1', + it('should keep two windows on the same day separate rather than spanning the gap', () => { + const text = blockText( + makeSession([ + { date: '2026-03-31', startTime: '08:00', endTime: '11:00' }, + { date: '2026-03-31', startTime: '14:00', endTime: '17:00' }, + ]), + 'pairing-dm-slots', ); - const actionsBlock = result.find(b => b.block_id === 'pairing-dm-actions') as any; - expect(actionsBlock).toBeDefined(); - const actionIds = actionsBlock.elements.map((e: any) => e.action_id); - expect(actionIds).toContain('pairing-submit-slots'); - expect(actionIds).toContain('pairing-decline-all'); + expect(text).toContain('Tue, Mar 31, 8 AM–11 AM'); + expect(text).toContain('Tue, Mar 31, 2 PM–5 PM'); + // The candidate is busy 11-2; claiming an 8-5 span would be a lie. + expect(text).not.toContain('8 AM–5 PM'); }); - it('should set button values to the threadId', () => { - const result = pairingRequestBuilder.buildTeammateDMBlocks( - { id: 'recruiter-1' }, - 'Dana', - ['Python'], - InterviewFormat.REMOTE, - slots, - 'thread-1', - ); + it('should not carry a checkbox list — times are picked in the modal now', () => { + const block = pairingRequestBuilder + .buildTeammateDMBlocks(makeSession()) + .find(b => b.block_id === 'pairing-dm-slots') as any; + + expect(block.accessory).toBeUndefined(); + }); + + it('should offer a picker button and a decline-all button, both carrying the threadId', () => { + const block = pairingRequestBuilder + .buildTeammateDMBlocks(makeSession()) + .find(b => b.block_id === 'pairing-dm-actions') as any; + + expect(block.elements.map((e: any) => e.action_id)).toEqual([ + 'pairing-open-picker', + 'pairing-decline-all', + ]); + block.elements.forEach((e: any) => expect(e.value).toBe('thread-1')); + }); + }); + + describe('buildTeammateDM', () => { + it('should address the DM to the teammate', () => { + const dm = pairingRequestBuilder.buildTeammateDM('teammate-1', makeSession()); - const actionsBlock = result.find(b => b.block_id === 'pairing-dm-actions') as any; - actionsBlock.elements.forEach((e: any) => { - if (e.action_id === 'pairing-submit-slots' || e.action_id === 'pairing-decline-all') { - expect(e.value).toBe('thread-1'); - } - }); + expect(dm.channel).toBe('teammate-1'); + expect(dm.text).toContain('Dana'); + expect(dm.blocks).toHaveLength(3); }); }); }); diff --git a/src/utils/__tests__/pairingPicker.test.ts b/src/utils/__tests__/pairingPicker.test.ts new file mode 100644 index 00000000..bf95b070 --- /dev/null +++ b/src/utils/__tests__/pairingPicker.test.ts @@ -0,0 +1,173 @@ +import { InterviewFormat } from '@bot/enums'; +import { PairingSession } from '@models/PairingSession'; +import { + buildPickerBlocks, + chipIndexFrom, + parseMeta, + PickerMeta, + pickerView, + serializeMeta, + snapshotOf, + timeToggleActionId, + toggleSelection, +} from '../pairingPicker'; +import { MAX_SESSIONS, slotsFromWindows } from '../pairingSlots'; + +const WINDOWS = [ + { date: '2026-03-31', startTime: '13:00', endTime: '17:00' }, // 13:00, 14:00 + { date: '2026-04-01', startTime: '08:00', endTime: '12:00' }, // 08:00, 09:00 +]; + +const WIDE_WEEK = Array.from({ length: 7 }, (_, i) => ({ + date: `2026-04-0${i + 1}`, + startTime: '08:00', + endTime: '19:00', +})); + +function makeSession(windows = WINDOWS): PairingSession { + return { + threadId: 'thread-1', + requestorId: 'recruiter-1', + candidateName: 'Dana', + languages: ['Python'], + format: InterviewFormat.REMOTE, + requestedAt: new Date('2026-03-30'), + teammatesNeededCount: 2, + availabilityWindows: windows, + slots: slotsFromWindows(windows), + pendingTeammates: [], + declinedTeammates: [], + }; +} + +function makeMeta(selected: number[] = [], windows = WINDOWS): PickerMeta { + const session = makeSession(windows); + return { + threadId: 'thread-1', + dmTs: 'dm-ts-1', + candidateName: session.candidateName, + languages: session.languages, + format: session.format, + slots: snapshotOf(session), + selected, + }; +} + +function actionBlocks(blocks: any[]): any[] { + return blocks.filter(b => b.type === 'actions'); +} + +function summaryOf(blocks: any[]): string { + return blocks.find(b => b.block_id === 'pairing-picker-summary').text.text; +} + +describe('toggleSelection', () => { + it('should add an unselected index', () => { + expect(toggleSelection([0, 2], 1)).toEqual([0, 1, 2]); + }); + + it('should remove an already-selected index', () => { + expect(toggleSelection([0, 1, 2], 1)).toEqual([0, 2]); + }); + + it('should keep selections in time order however they were tapped', () => { + expect(toggleSelection([3], 0)).toEqual([0, 3]); + }); +}); + +describe('chipIndexFrom', () => { + it('should recover the index from a chip action id', () => { + expect(chipIndexFrom(timeToggleActionId(12))).toBe(12); + }); + + it('should not match the other pairing actions', () => { + expect(chipIndexFrom('pairing-decline-all')).toBeUndefined(); + expect(chipIndexFrom('pairing-open-picker')).toBeUndefined(); + }); +}); + +describe('meta serialization', () => { + it('should round-trip', () => { + const meta = makeMeta([1, 3]); + + expect(parseMeta(serializeMeta(meta))).toEqual(meta); + }); + + it('should survive an empty payload rather than throwing', () => { + expect(parseMeta(undefined)).toEqual(expect.objectContaining({ slots: [], selected: [] })); + }); + + it('should stay inside Slack’s 3000 character budget with a full week and everything picked', () => { + const meta = makeMeta([], WIDE_WEEK); + const everything = { ...meta, selected: meta.slots.map((_, i) => i) }; + + expect(meta.slots).toHaveLength(63); + expect(serializeMeta(everything).length).toBeLessThan(3000); + }); + + it('should still fit at the MAX_SESSIONS cap the recruiter form enforces', () => { + // If this fails, MAX_SESSIONS is too high and the picker modal will refuse to open. + const slots = Array.from({ length: MAX_SESSIONS }, () => ({ + date: '2026-04-01', + startTime: '08:00', + endTime: '11:00', + })); + const meta = { ...makeMeta(), slots, selected: slots.map((_, i) => i) }; + + expect(serializeMeta(meta).length).toBeLessThan(3000); + }); +}); + +describe('buildPickerBlocks', () => { + it('should render one actions block per day', () => { + expect(actionBlocks(buildPickerBlocks(makeMeta()))).toHaveLength(2); + }); + + it('should render one chip per bookable session, labelled with its start time', () => { + const [day1, day2] = actionBlocks(buildPickerBlocks(makeMeta())); + + expect(day1.elements.map((e: any) => e.text.text)).toEqual(['1 PM', '2 PM']); + expect(day2.elements.map((e: any) => e.text.text)).toEqual(['8 AM', '9 AM']); + }); + + it('should style only the selected chips as primary', () => { + const chips = actionBlocks(buildPickerBlocks(makeMeta([1, 2]))).flatMap(b => b.elements); + + expect(chips.map((c: any) => c.style)).toEqual([undefined, 'primary', 'primary', undefined]); + }); + + it('should summarize the picked sessions using their stored end times', () => { + const summary = summaryOf(buildPickerBlocks(makeMeta([0, 3]))); + + expect(summary).toContain('2 picked'); + expect(summary).toContain('Tue, Mar 31, 1 PM–4 PM'); + expect(summary).toContain('Wed, Apr 1, 9 AM–12 PM'); + }); + + it('should warn that submitting with nothing picked passes on the session', () => { + expect(summaryOf(buildPickerBlocks(makeMeta()))).toContain('passes on this session'); + }); + + it('should ignore a selection index that no longer maps to a slot', () => { + expect(summaryOf(buildPickerBlocks(makeMeta([0, 99])))).toContain('1 picked'); + }); + + it('should stay well under Slack’s 100-block modal cap for a full week of wide windows', () => { + const blocks = buildPickerBlocks(makeMeta([], WIDE_WEEK)); + + expect(blocks.length).toBeLessThan(100); + // Slack rejects an actions block holding more than 25 elements. + actionBlocks(blocks).forEach(b => expect(b.elements.length).toBeLessThanOrEqual(25)); + }); +}); + +describe('pickerView', () => { + it('should carry the whole picker state in private_metadata', () => { + const meta = makeMeta([2]); + + const view = pickerView(meta); + + expect(view.callback_id).toBe('submit-pairing-times'); + expect(parseMeta(view.private_metadata as string)).toEqual(meta); + }); +}); diff --git a/src/utils/__tests__/pairingSlots.test.ts b/src/utils/__tests__/pairingSlots.test.ts new file mode 100644 index 00000000..92a11ecb --- /dev/null +++ b/src/utils/__tests__/pairingSlots.test.ts @@ -0,0 +1,178 @@ +import { PairingSlot } from '@models/PairingSession'; +import { groupByDate, sliceWindow, slotsFromWindows, validateWindow } from '../pairingSlots'; + +function times(slots: PairingSlot[]): string[] { + return slots.map(s => `${s.startTime}-${s.endTime}`); +} + +describe('validateWindow', () => { + it('should accept a window exactly long enough for a session', () => { + expect(validateWindow('08:00', '11:00')).toBeUndefined(); + }); + + it('should accept a wide window', () => { + expect(validateWindow('08:00', '19:00')).toBeUndefined(); + }); + + it('should reject an end time before the start time', () => { + expect(validateWindow('17:00', '08:00')).toBe('End time must be after start time.'); + }); + + it('should reject an end time equal to the start time', () => { + expect(validateWindow('08:00', '08:00')).toBe('End time must be after start time.'); + }); + + it('should reject a window too short to hold a session', () => { + expect(validateWindow('13:00', '15:00')).toBe( + "A 3 hour session doesn't fit — this window is only 2 hours.", + ); + }); + + it('should describe a fractional window in the error', () => { + expect(validateWindow('13:00', '14:00')).toBe( + "A 3 hour session doesn't fit — this window is only 1 hour.", + ); + }); +}); + +describe('sliceWindow', () => { + it('should slice a full business day into hourly starts', () => { + const slots = sliceWindow('2026-03-31', '08:00', '17:00'); + + expect(times(slots)).toEqual([ + '08:00-11:00', + '09:00-12:00', + '10:00-13:00', + '11:00-14:00', + '12:00-15:00', + '13:00-16:00', + '14:00-17:00', + ]); + }); + + it('should not offer a start whose session would run past the window', () => { + const slots = sliceWindow('2026-03-31', '13:00', '17:00'); + + expect(times(slots)).toEqual(['13:00-16:00', '14:00-17:00']); + }); + + it('should yield exactly one session when the window is exactly one session long', () => { + const slots = sliceWindow('2026-03-31', '16:00', '19:00'); + + expect(times(slots)).toEqual(['16:00-19:00']); + }); + + it('should yield nothing for a window too short to hold a session', () => { + expect(sliceWindow('2026-03-31', '13:00', '15:00')).toEqual([]); + }); + + it('should yield nothing for an inverted window', () => { + expect(sliceWindow('2026-03-31', '17:00', '08:00')).toEqual([]); + }); + + it('should carry the date and give every session a unique id', () => { + const slots = sliceWindow('2026-03-31', '08:00', '12:00'); + + expect(slots.every(s => s.date === '2026-03-31')).toBe(true); + expect(new Set(slots.map(s => s.id)).size).toBe(slots.length); + expect(slots.every(s => s.interestedTeammates.length === 0)).toBe(true); + }); + + it('should stay within Slack’s 25-buttons-per-block cap on the widest realistic window', () => { + expect(sliceWindow('2026-03-31', '08:00', '19:00')).toHaveLength(9); + }); + + it('should snap to the hour — the recruiter’s form promises whole-hour starts', () => { + // Slack's timepicker accepts any minute, so 08:15 must not yield starts of 8:15, 9:15, 10:15… + const slots = sliceWindow('2026-03-31', '08:15', '17:00'); + + expect(times(slots)).toEqual([ + '09:00-12:00', + '10:00-13:00', + '11:00-14:00', + '12:00-15:00', + '13:00-16:00', + '14:00-17:00', + ]); + }); + + it('should not offer a start before an off-the-hour window opens', () => { + expect(times(sliceWindow('2026-03-31', '13:30', '17:00'))).toEqual(['14:00-17:00']); + }); +}); + +describe('slotsFromWindows', () => { + it('should slice every window', () => { + const slots = slotsFromWindows([ + { date: '2026-03-31', startTime: '13:00', endTime: '17:00' }, + { date: '2026-04-01', startTime: '08:00', endTime: '12:00' }, + ]); + + expect(slots.map(s => `${s.date} ${s.startTime}`)).toEqual([ + '2026-03-31 13:00', + '2026-03-31 14:00', + '2026-04-01 08:00', + '2026-04-01 09:00', + ]); + }); + + it('should not emit two slots for the same time when windows overlap on a day', () => { + const slots = slotsFromWindows([ + { date: '2026-03-31', startTime: '08:00', endTime: '13:00' }, + { date: '2026-03-31', startTime: '09:00', endTime: '14:00' }, + ]); + + expect(slots.map(s => s.startTime)).toEqual(['08:00', '09:00', '10:00', '11:00']); + expect(new Set(slots.map(s => s.id)).size).toBe(slots.length); + }); + + it('should keep two windows on the same day distinct when they do not overlap', () => { + const slots = slotsFromWindows([ + { date: '2026-03-31', startTime: '08:00', endTime: '11:00' }, + { date: '2026-03-31', startTime: '14:00', endTime: '17:00' }, + ]); + + expect(slots.map(s => s.startTime)).toEqual(['08:00', '14:00']); + }); + + it('should sort slots chronologically however the windows were entered', () => { + const slots = slotsFromWindows([ + { date: '2026-04-02', startTime: '08:00', endTime: '11:00' }, + { date: '2026-04-01', startTime: '13:00', endTime: '16:00' }, + ]); + + expect(slots.map(s => s.date)).toEqual(['2026-04-01', '2026-04-02']); + }); +}); + +describe('groupByDate', () => { + it('should group by day in the order the days first appear', () => { + const slots = [ + ...sliceWindow('2026-04-01', '08:00', '11:00'), + ...sliceWindow('2026-03-31', '08:00', '12:00'), + ]; + + const grouped = groupByDate(slots); + + expect(grouped.map(([date, entries]) => [date, entries.length])).toEqual([ + ['2026-04-01', 1], + ['2026-03-31', 2], + ]); + }); + + it('should carry each item’s index in the flat list, since callers address slots by position', () => { + const slots = [ + ...sliceWindow('2026-04-01', '08:00', '11:00'), + ...sliceWindow('2026-03-31', '08:00', '12:00'), + ]; + + const grouped = groupByDate(slots); + + expect(grouped[0][1].map(e => e.index)).toEqual([0]); + expect(grouped[1][1].map(e => e.index)).toEqual([1, 2]); + }); + + it('should return nothing for no items', () => { + expect(groupByDate([])).toEqual([]); + }); +}); diff --git a/src/utils/pairingPicker.ts b/src/utils/pairingPicker.ts new file mode 100644 index 00000000..a4ed4d26 --- /dev/null +++ b/src/utils/pairingPicker.ts @@ -0,0 +1,137 @@ +import { ActionId, BlockId, Interaction, InterviewFormat, formatLabel } from '@bot/enums'; +import { PairingSession } from '@models/PairingSession'; +import { Button, KnownBlock, View } from '@slack/types'; +import { PAIRING_SESSION_HOURS, groupByDate } from '@utils/pairingSlots'; +import { bold, compose, formatDate, formatSlot, formatTime, textBlock, ul } from '@utils/text'; + +/** A slot as the open picker knows it. Snapshotted so a repaint needs no database read. */ +export interface PickerSlot { + date: string; + startTime: string; + endTime: string; +} + +/** + * The picker's whole world, carried in private_metadata. + * + * The snapshot is here rather than re-read per tap because getByThreadIdOrUndefined pulls every row + * of the sheet, and a repaint that costs a full fetch would burn the read quota on a fast clicker. + * A view is a pure function of (slots, selected) — selection is never inferred from button styling, + * so a chip styled for some other reason later can't read as picked. + * + * `dmTs` is captured on open because a view_submission payload carries no `message`, and it's the + * only handle on the DM we later collapse. + */ +export interface PickerMeta { + threadId: string; + dmTs: string; + candidateName: string; + languages: string[]; + format: InterviewFormat; + slots: PickerSlot[]; + selected: number[]; +} + +/** Slack allows 3000 characters of private_metadata; JSON objects per slot would crowd it. */ +function encodeSlots(slots: PickerSlot[]): string { + return slots.map(s => `${s.date}|${s.startTime}|${s.endTime}`).join(';'); +} + +function decodeSlots(encoded: string): PickerSlot[] { + if (!encoded) return []; + return encoded.split(';').map(entry => { + const [date, startTime, endTime] = entry.split('|'); + return { date, startTime, endTime }; + }); +} + +export function serializeMeta(meta: PickerMeta): string { + return JSON.stringify({ ...meta, slots: encodeSlots(meta.slots) }); +} + +export function parseMeta(privateMetadata: string | undefined): PickerMeta { + const raw = JSON.parse(privateMetadata || '{}'); + return { ...raw, slots: decodeSlots(raw.slots ?? ''), selected: raw.selected ?? [] }; +} + +export function snapshotOf(session: PairingSession): PickerSlot[] { + return session.slots.map(({ date, startTime, endTime }) => ({ date, startTime, endTime })); +} + +export function toggleSelection(selected: number[], index: number): number[] { + return selected.includes(index) + ? selected.filter(i => i !== index) + : [...selected, index].sort((a, b) => a - b); +} + +export function timeToggleActionId(index: number): string { + return `${ActionId.PAIRING_TOGGLE_TIME}-${index}`; +} + +/** Matches every chip in the grid, whatever its index. */ +export const TIME_TOGGLE_PATTERN = new RegExp(`^${ActionId.PAIRING_TOGGLE_TIME}-(\\d+)$`); + +export function chipIndexFrom(actionId: string): number | undefined { + const match = TIME_TOGGLE_PATTERN.exec(actionId); + return match ? Number(match[1]) : undefined; +} + +function chip(slot: PickerSlot, index: number, isSelected: boolean): Button { + return { + type: 'button', + action_id: timeToggleActionId(index), + text: { type: 'plain_text', text: formatTime(slot.startTime) }, + value: String(index), + ...(isSelected ? { style: 'primary' as const } : {}), + }; +} + +function summaryBlock(slots: PickerSlot[], selected: number[]): KnownBlock { + const picked = selected.map(i => slots[i]).filter(slot => slot != null); + const text = + picked.length === 0 + ? "You haven't picked any times yet. Submitting now passes on this session." + : compose( + bold(`${picked.length} picked:`), + ul(...picked.map(s => formatSlot(s.date, s.startTime, s.endTime))), + ); + return { ...textBlock(text), block_id: BlockId.PAIRING_PICKER_SUMMARY } as KnownBlock; +} + +export function buildPickerBlocks(meta: PickerMeta): KnownBlock[] { + const selected = new Set(meta.selected); + + const blocks: KnownBlock[] = [ + textBlock( + compose( + `Pairing with *${meta.candidateName}* — ${meta.languages.join(', ')}, ${formatLabel(meta.format)}.`, + `Each time below starts a *${PAIRING_SESSION_HOURS} hour* session. Tap every one that works for you. All times Central.`, + ), + ), + ]; + + for (const [date, slots] of groupByDate(meta.slots)) { + blocks.push(textBlock(bold(formatDate(date)))); + blocks.push({ + type: 'actions', + elements: slots.map(({ item, index }) => chip(item, index, selected.has(index))), + }); + } + + blocks.push({ type: 'divider' }); + blocks.push(summaryBlock(meta.slots, meta.selected)); + + return blocks; +} + +export function pickerView(meta: PickerMeta): View { + return { + type: 'modal', + callback_id: Interaction.SUBMIT_PAIRING_TIMES, + title: { type: 'plain_text', text: 'Pick your times' }, + submit: { type: 'plain_text', text: 'Submit' }, + close: { type: 'plain_text', text: 'Cancel' }, + private_metadata: serializeMeta(meta), + blocks: buildPickerBlocks(meta), + }; +} diff --git a/src/utils/pairingSlots.ts b/src/utils/pairingSlots.ts new file mode 100644 index 00000000..2193a045 --- /dev/null +++ b/src/utils/pairingSlots.ts @@ -0,0 +1,126 @@ +import { AvailabilityWindow, PairingSlot } from '@models/PairingSession'; + +/** A pairing session is a fixed-length commitment, regardless of how wide the candidate's window is. */ +export const PAIRING_SESSION_HOURS = 3; + +/** Teammates pick a start time on the hour. */ +const START_INTERVAL_MINUTES = 60; + +/** + * The picker carries every slot in Slack's 3000-character private_metadata, ~23 characters each. + * Past this the modal fails to open at all, so the recruiter has to be stopped at submit instead. + * Seven days of 8 AM–7 PM is 63 — this only bites on windows far wider than a working day. + */ +export const MAX_SESSIONS = 90; + +function toMinutes(hhmm: string): number { + const [hours, minutes] = hhmm.split(':').map(Number); + return hours * 60 + minutes; +} + +function ceilToHour(minutes: number): number { + return Math.ceil(minutes / 60) * 60; +} + +function toHHMM(minutes: number): string { + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; +} + +/** + * Returns an error message if a session can't be booked inside this window, otherwise undefined. + */ +export function validateWindow(startTime: string, endTime: string): string | undefined { + const start = toMinutes(startTime); + const end = toMinutes(endTime); + + if (end <= start) { + return 'End time must be after start time.'; + } + + const windowHours = (end - start) / 60; + if (windowHours < PAIRING_SESSION_HOURS) { + return `A ${PAIRING_SESSION_HOURS} hour session doesn't fit — this window is only ${formatHours(windowHours)}.`; + } + + return undefined; +} + +function formatHours(hours: number): string { + const rounded = Math.round(hours * 10) / 10; + return `${rounded} hour${rounded === 1 ? '' : 's'}`; +} + +/** + * Slices a window of candidate availability into the bookable sessions inside it. + * + * Only start times where the full session fits before the window closes are offered, so any slot a + * teammate picks is bookable as-is. An 8:00–17:00 window yields starts at 8, 9, 10, 11, 12, 13, 14. + */ +export function sliceWindow(date: string, startTime: string, endTime: string): PairingSlot[] { + if (validateWindow(startTime, endTime)) return []; + + // Slack's timepicker accepts any minute, so a window of 08:15 would otherwise offer starts of + // 8:15, 9:15, 10:15… The recruiter's form promises whole hours; snap up to the next one. + const windowStart = ceilToHour(toMinutes(startTime)); + const windowEnd = toMinutes(endTime); + const sessionMinutes = PAIRING_SESSION_HOURS * 60; + + const slots: PairingSlot[] = []; + for ( + let start = windowStart; + start + sessionMinutes <= windowEnd; + start += START_INTERVAL_MINUTES + ) { + slots.push({ + id: crypto.randomUUID(), + date, + startTime: toHHMM(start), + endTime: toHHMM(start + sessionMinutes), + interestedTeammates: [], + }); + } + return slots; +} + +/** + * Slices every window and drops sessions that repeat one already produced. + * + * Nothing stops a recruiter entering two windows for the same day, and overlapping ones would + * otherwise yield two distinct slot ids for the same wall-clock time — which teammates see as two + * identical chips, and which quietly splits their picks so a slot can never reach confirmation. + */ +export function slotsFromWindows(windows: AvailabilityWindow[]): PairingSlot[] { + const seen = new Set(); + const slots: PairingSlot[] = []; + for (const window of windows) { + for (const slot of sliceWindow(window.date, window.startTime, window.endTime)) { + const key = `${slot.date}T${slot.startTime}`; + if (seen.has(key)) continue; + seen.add(key); + slots.push(slot); + } + } + return slots.sort((a, b) => `${a.date}T${a.startTime}`.localeCompare(`${b.date}T${b.startTime}`)); +} + +/** + * Groups anything dated by its date, preserving the order the dates first appear in and carrying + * each item's original index — callers render per day but address slots by position in the flat list. + */ +export function groupByDate( + items: T[], +): Array<[string, Array<{ item: T; index: number }>]> { + const byDate = new Map>(); + items.forEach((item, index) => { + const entry = { item, index }; + const existing = byDate.get(item.date); + if (existing) { + existing.push(entry); + } else { + byDate.set(item.date, [entry]); + } + }); + return Array.from(byDate.entries()); +} diff --git a/src/utils/text.ts b/src/utils/text.ts index 8c9a445d..6560c749 100644 --- a/src/utils/text.ts +++ b/src/utils/text.ts @@ -50,14 +50,18 @@ export function textBlock(text: string): KnownBlock { } export function formatSlot(date: string, startTime: string, endTime: string): string { + return `${formatDate(date)}, ${formatTime(startTime)}–${formatTime(endTime)}`; +} + +export function formatDate(date: string): string { const [year, month, day] = date.split('-').map(Number); const dateObj = new Date(year, month - 1, day); const dayOfWeek = dateObj.toLocaleDateString('en-US', { weekday: 'short' }); const monthStr = dateObj.toLocaleDateString('en-US', { month: 'short' }); - return `${dayOfWeek}, ${monthStr} ${day}, ${formatTime(startTime)}–${formatTime(endTime)}`; + return `${dayOfWeek}, ${monthStr} ${day}`; } -function formatTime(hhmm: string): string { +export function formatTime(hhmm: string): string { const [hourStr, minuteStr] = hhmm.split(':'); const hour = Number(hourStr); const minute = Number(minuteStr);