Skip to content

feat: let teammates pick a concrete 3-hour session from a time grid#567

Merged
kylehoehns merged 4 commits into
mainfrom
feat/pairing-time-picker
Jul 14, 2026
Merged

feat: let teammates pick a concrete 3-hour session from a time grid#567
kylehoehns merged 4 commits into
mainfrom
feat/pairing-time-picker

Conversation

@kylehoehns

Copy link
Copy Markdown
Contributor

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:00 becomes 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.

WED 15
[ 8:00am ] [ 9:00am ] [ 10:00am ] [ 11:00am ] [✓12:00pm ] [ 1:00pm ] [ 2:00pm ]

2 picked: Wed 12 PM–3 PM · Thu 9 AM–12 PM

What reviewers should push on

Confirmation is now stricter, and the close rate will drop. isSlotConfirmed requires 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

  • Adds an availabilityWindows column to the pairing_sessions sheet. It is appended, not insertedsetHeaderRow writes positionally, so a mid-list column would relabel every column after it and misalign existing rows. Two tests pin this.
  • The recruiter's windows are stored, not inferred back from the slices. Inferring collapses two windows on one day (8–11 and 2–5) into one false span (8–5) that claims the candidate is free when they aren't.
  • Chip toggles repaint from the view Slack sends us rather than re-reading the session — getByThreadIdOrUndefined pulls every row of the sheet, and a fetch per tap would burn the Sheets read quota.
  • MAX_SLOTS (20) became MAX_WINDOWS (7) — days, not slots. Seven business-hours days is ~63 sessions, comfortably inside Slack's 100-block and 25-button-per-block caps.
  • Deleted the old checkbox path (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 verify passes — 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-hash guard. The "N picked" summary line exists so a dropped toggle is visible.

🤖 Generated with Claude Code

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 thread src/bot/requestPairingSession.ts Outdated
Comment on lines +242 to +244
const meta: ModalMeta = JSON.parse(
view?.private_metadata || '{"windowCount":1,"languages":[]}',
);
Comment thread src/bot/requestPairingSession.ts Outdated
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.
@kylehoehns kylehoehns marked this pull request as ready for review July 14, 2026 16:16
@kylehoehns kylehoehns merged commit 7864625 into main Jul 14, 2026
1 check passed
@kylehoehns kylehoehns deleted the feat/pairing-time-picker branch July 14, 2026 16:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants