feat: let teammates pick a concrete 3-hour session from a time grid#567
Merged
Conversation
Recruiters were entering a candidate's availability as one wide window
("Wed 8 AM-5 PM") and we were showing that window to teammates as the thing
to opt into. Checking that box didn't commit anyone to anything -- it still
took a round of DMs to pin down the actual hour -- so people didn't check it.
Now the recruiter still enters the honest window, and the bot slices it into
the 3-hour sessions that fit inside it. Teammates get a tap-to-select grid of
concrete start times, and because every option sits inside what the candidate
agreed to, a picked time is bookable as-is.
- Slice windows into hourly-start, 3-hour sessions at request time
- Replace the DM's checkbox list with a "Pick your times" button and a modal
of toggleable time chips
- Store the recruiter's windows alongside the sliced sessions; inferring them
back from the slices collapses two windows on one day into one false span
- Validate windows on submit (end after start, long enough to hold a session)
via response_action errors instead of silently dropping them
- Cap the form at 7 days, which keeps the picker inside Slack's block limits
- Group slots by day in getReviewInfo so the modal stays readable
Confirmation now requires two teammates on the same start time rather than on
the same all-day blob, so a "confirmed" session is genuinely schedulable. Fewer
sessions will close, but the ones that do are real.
Note: adds an availabilityWindows column to the pairing_sessions sheet. It is
appended, not inserted -- setHeaderRow writes positionally, so a mid-list
column would relabel every column after it and misalign existing rows.
The picker's view is now a pure function of (slots, selected), both carried in private_metadata. Previously a repaint walked the rendered blocks and inferred selection from `style === 'primary'`, which made a presentation attribute load-bearing -- a chip styled danger for "already full" would have silently read as picked -- and forced each button to smuggle its own date and start time through a pipe-delimited value string. Snapshotting the slots keeps the property that mattered (a tap costs zero sheet reads) without any of that. - Resolve submitted picks by date+startTime instead of by array position, so a future reordering of session.slots can't book someone into the wrong time - One windowBlockIds() helper: the renderer and the reader built those strings independently, and a drift would have silently dropped a recruiter's whole day - Send the initial teammate DMs concurrently rather than 2N serialized calls - Fold both decline paths into one, and give the picker/DM/confirmation a single shared session header - Reuse textBlock(); add formatLabel() for the five InterviewFormatLabel sites - groupSlotsByDate -> generic groupByDate carrying each item's index, replacing an identity-keyed Map and an `as number` cast
Three real bugs found in review. 1. Infinite re-DM loop. recordSlotSelections silently skips slots that already have enough teammates. When that was true of EVERY slot a teammate picked, they were removed from pendingTeammates and added nowhere else -- and nextInLineForPairing excludes only pending/declined/interested users, so it picked the same person again (still sorted first, since their last-reviewed date only moves on close) and DM'd them the identical session, forever. Needs hybrid to trigger, since only there can a slot sit full-but-unconfirmed. Now: if no picked slot has room, decline them, which is what actually happened. 2. The DM race told teammates a lie and destroyed their buttons. The session row is created before its DMs go out, so between the DM landing and pendingTeammates being written a teammate legitimately isn't on the list. The old code treated that identically to "someone else took it" and collapsed their DM. A finished session is removed from the sheet outright, so the two cases ARE separable: standingOf() now distinguishes gone / responded / setting-up, and the ambiguous case posts a new "still being set up, try again" message and leaves the DM alone. 3. Slack's timepicker accepts any minute, so a window of 08:15 produced starts of 8:15, 9:15, 10:15... while the recruiter's form promises whole hours. Snap up. Also cap generated sessions at MAX_SESSIONS (90). The picker carries every slot in Slack's 3000-char private_metadata; past the budget the modal doesn't open at all and the teammate's button fails with nothing to explain it. Seven days of 8 AM-7 PM is 63, so this only bites on windows far wider than a working day.
There was a problem hiding this comment.
Pull request overview
This PR changes pairing-session availability from “wide windows teammates check” to “concrete 3-hour sessions teammates pick,” by slicing recruiter-entered availability windows into hourly-start sessions and introducing a Slack modal time-grid picker to select specific start times.
Changes:
- Add window-slicing utilities (
validateWindow,sliceWindow,slotsFromWindows) and a date-grouping helper for per-day rendering. - Replace the DM checkbox flow with a “Pick your times” button that opens a modal grid picker, plus new handlers for open/toggle/submit.
- Persist recruiter-entered availability windows (
availabilityWindows) on the pairing session model and in the sheet repo, and update rendering/tests accordingly.
Reviewed changes
Copilot reviewed 25 out of 25 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/utils/text.ts | Splits slot formatting into formatDate/formatTime, updates formatSlot to reuse them. |
| src/utils/pairingSlots.ts | Adds availability-window validation and slicing into fixed 3-hour sessions; adds groupByDate. |
| src/utils/PairingRequestBuilder.ts | Updates teammate DM blocks to show windows + picker button instead of checkboxes. |
| src/utils/pairingPicker.ts | Introduces picker metadata serialization and modal block rendering for time-grid selection. |
| src/utils/tests/pairingSlots.test.ts | Adds coverage for window validation, slicing, dedupe, and grouping. |
| src/utils/tests/PairingRequestBuilder.test.ts | Updates tests for new DM content (windows + picker button) and shared header. |
| src/utils/tests/pairingPicker.test.ts | Adds tests for picker encoding, toggling behavior, rendering, and modal metadata size limits. |
| src/services/PairingRequestService.ts | Adds slotsWithRoomFor helper to avoid “picked-all-full” loops before recording selections. |
| src/services/tests/PairingSessionCloser.test.ts | Updates session fixtures to include availabilityWindows. |
| src/services/tests/PairingRequestService.test.ts | Updates session fixtures to include availabilityWindows. |
| src/services/tests/PairingQueueService.test.ts | Updates session fixtures to include availabilityWindows. |
| src/database/repos/pairingSessionsRepo.ts | Adds persisted availabilityWindows column (appended) with backward-compatible read default. |
| src/database/repos/tests/pairingSessionsRepo.test.ts | Adds tests asserting column is last + missing-column reads as empty windows. |
| src/database/models/PairingSession.ts | Adds AvailabilityWindow type and stores availabilityWindows on PairingSession. |
| src/cron/tests/expirePairingRequests.test.ts | Updates session fixtures to include availabilityWindows. |
| src/bot/requestPairingSession.ts | Changes recruiter modal from “slots” to “windows,” validates/slices to sessions, caps by MAX_SESSIONS, parallelizes DM sends. |
| src/bot/pickPairingTimes.ts | New interaction handlers for opening picker modal, toggling chips, submitting picks, and declining. |
| src/bot/getReviewInfo.ts | Renders session availability grouped by day to stay under Slack block limits. |
| src/bot/enums.ts | Adds new interaction/action IDs for picker flow; adds formatLabel helper. |
| src/bot/acceptPairingSlot.ts | Removes legacy checkbox-based submit handler. |
| src/bot/tests/requestPairingSession.test.ts | Updates tests for window-based recruiter flow and validation errors. |
| src/bot/tests/pickPairingTimes.test.ts | Adds tests for picker open/toggle/submit/decline behaviors and race handling. |
| src/bot/tests/getReviewInfo.test.ts | Updates session fixtures to include availabilityWindows. |
| src/bot/tests/acceptPairingSlot.test.ts | Removes legacy checkbox handler tests. |
| src/app.ts | Switches app setup from acceptPairingSlot to pickPairingTimes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+242
to
+244
| const meta: ModalMeta = JSON.parse( | ||
| view?.private_metadata || '{"windowCount":1,"languages":[]}', | ||
| ); |
Comment on lines
+265
to
+266
| meta = JSON.parse((body as any).view.private_metadata || '{"windowCount":1,"languages":[]}'); | ||
| const windows = readWindows(body, meta.windowCount); |
Comment on lines
+30
to
+34
| compose( | ||
| `Sessions run *${PAIRING_SESSION_HOURS} hours*. ${session.candidateName} is available:`, | ||
| ul(...session.availabilityWindows.map(w => formatSlot(w.date, w.startTime, w.endTime))), | ||
| 'Pick the start times that work for you — whatever you pick, we book.', | ||
| ), |
Comment on lines
+247
to
+259
| async function reportStanding( | ||
| client: WebClient, | ||
| userId: string, | ||
| dmTs: string, | ||
| standing: TeammateStanding, | ||
| ): Promise<void> { | ||
| 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; | ||
| } | ||
| await chatService.updateDirectMessage(client, userId, dmTs, [textBlock(ALREADY_FILLED)]); | ||
| } |
- Don't tell a teammate who already responded that the session "was filled by
someone else." They answered it, and it may not even be filled. 'responded' and
'gone' now get different copy.
- Normalize the recruiter modal's private_metadata in one place. A modal opened
before this deploy carries {slotCount}, not {windowCount}; the undefined count
made Array.from({length: undefined}) render a form with zero window blocks, and
handleAddWindow compute NaN + 1. readModalMeta accepts either key and clamps.
- Render a fallback line when availabilityWindows is empty instead of a blank
paragraph. Cosmetic only: such a session's slots are also un-sliced, so its
picker still misstates every session's length. Drain the sheet before deploying.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
Recruiters enter a candidate's availability as one wide window — "Wed, 8 AM–5 PM" — and we show that window to teammates as the thing to opt into.
But checking that box doesn't commit anyone to anything. It still takes a round of DMs to pin down the actual hour, and neither the teammate nor the candidate knows whether "Wed" means 8 AM or 2 PM. So people don't check it.
The recruiters aren't being lazy — "the candidate is free any time Tuesday" is often simply true. The bug is that we take the candidate's availability and present it as the teammate's commitment. Those are two different things.
The fix
The recruiter keeps entering the honest window. The bot slices it into the 3-hour sessions that fit inside it, and teammates pick from those.
Because every option sits inside what the candidate already agreed to, a picked time is bookable as-is — no follow-up negotiation.
Recruiter
Same form, honest label.
Wed 8:00–17:00becomes starts at 8, 9, 10, 11, 12, 1, and 2.Teammate
The DM's checkbox list is replaced by a Pick your times button, which opens a tap-to-select grid — one row per day, chips turn green when selected.
What reviewers should push on
Confirmation is now stricter, and the close rate will drop.
isSlotConfirmedrequires two teammates on the same start time. Previously any two people who ticked the same all-day blob "confirmed" it — but that confirmation was fake, and could fall apart in DMs. Now it's real. Fewer sessions will close; the ones that do are genuinely schedulable. That's a deliberate trade, and it's the number worth watching after this ships.Teammates who are only free 9–12 have no escape hatch. Hourly starts mean most schedules land on the grid, but not all. If people get stuck, the follow-up is a "propose another time" button.
Notes
availabilityWindowscolumn to thepairing_sessionssheet. It is appended, not inserted —setHeaderRowwrites positionally, so a mid-list column would relabel every column after it and misalign existing rows. Two tests pin this.getByThreadIdOrUndefinedpulls every row of the sheet, and a fetch per tap would burn the Sheets read quota.MAX_SLOTS(20) becameMAX_WINDOWS(7) — days, not slots. Seven business-hours days is ~63 sessions, comfortably inside Slack's 100-block and 25-button-per-block caps.acceptPairingSlot.ts). In-flight sessions at deploy time hold DMs whose Submit button no longer has a handler — cleanest to deploy when the sheet is empty, though thanks to the appended column a deploy with live rows no longer corrupts anything, it just leaves a few dead buttons until those requests expire.Testing
pnpm verifypasses — 257 tests, 34 suites.Not yet driven in a live Slack workspace. Two things only a real workspace will show: how laggy a chip tap actually feels (each is a round trip plus a full modal repaint), and whether fast clicking drops a selection despite the view-
hashguard. The "N picked" summary line exists so a dropped toggle is visible.🤖 Generated with Claude Code