Repository navigation
Email participants when the organizer moves their slot to a new date or time - #567
Conversation
When an organizer changes the date or time of a slot people hold, each of them now gets one "Time changed" email with the new details, about 10 minutes after the last change. updateSlot records a slot.rescheduled row with where the slot moved from and to; a worker scan every 2 minutes picks commitments whose slot has sat still for 10 minutes and whose last email (confirmation, reminder or change email) was for another time. It follows the reminders setting and the participant's unsubscribe, sharing those rules with the reminder scan. A reminder for a time the participant was already told about now reads as a normal reminder. The slot editor in the Build tab says how many people are signed up and whether they'll be told about a move. Fixes #524. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LBWyCHhNPaKX2GtNcNGzCF
Tophat: passedI ran 882ec37 locally with
Not a failure, for the owner: a move made while reminders are off is not forgotten. I moved the slot to Apr 27 with reminders off, and nothing went out. When I turned reminders back on, the next scan emailed Sam "Time changed … Sat, Apr 27", because what Sam was last told (Apr 20) still differed from the slot. That follows from the "last told vs now" design, and it gives the participant the right time. But the editor note ("they won't be told if you change it") and the help text ("if you turn reminders off, nobody is told when you move a slot") read as if the move will never be told. It only stays untold while reminders are off. Within the 7-day lookback, turning reminders back on sends the email. For the owner (UX): participants now get a "Your slot has changed" email about 10–12 minutes after the organizer last changes a slot's date or time. Its subject starts "Time changed:". It lists every field, with the manage link and an unsubscribe link that stops these emails along with reminders. In the builder, a slot with sign-ups shows a blue note saying how many people are signed up and whether they'll be emailed. The unsubscribe page, the privacy page and the help article say the change emails exist. See Screenshots are in https://claude.ai/code/session_01GZdAJ7AgwrXMcDs8nmZM1r. Generated by Claude Code |
richshaw
left a comment
There was a problem hiding this comment.
Review: changes needed (round 1)
The PR does what #524 asks, and the design holds up. updateSlot records a slot.rescheduled row inside the slot's own transaction. A worker scan sends the email once the slot has settled, and the send job asks the same rules again. "Last told" comes from the activity log. The reminder and change-email scans share notifiableRules, so the reminders switch and an unsubscribe silence both. Each "Done when" item is done and has a DB test: one email per commitment, the debounce, a move and move back sending nothing, cancelled, deleted, reminders off, unsubscribed, the switches flipped during the wait, and a reminder after a change email reading as a plain "Reminder". The privacy page, the unsubscribe page, the help article, docs/telemetry.md, CLAUDE.md and CHANGELOG are all updated. One claim isn't fully delivered: a field change that re-times slots is supposed to send nothing, but it does send when the slot moved in the last week.
Blocking (1):
src/jobs/slot-changes.ts:45-55: after a move in the last 7 days, arebuildSlotAtre-time (switching the reminder date field, deleting the time field) passes every rule. Everyone holding the slot gets "moved your slot to a new date or time" although no value changed. Send only when the slot's currentslot_atis thetoof its newestslot.rescheduled, and add a DB test for it.
Optional (6):
src/email/subjects.ts:20-25:slotChangedSubjectwas inserted betweenreminderSubject's JSDoc andreminderSubject. Thechangedcomment now sits on the wrong function, and it still gives the old meaning ("follows an earlier one"). Move it back and reword it for "last email was for another time".src/email/templates/reminder.tsx:21and:72: the prop doc still says reminders are "the only email it silences", and the link reads "Stop reminders for …". The privacy page now says every reminder's link stops both. The change email's own link already reads "Stop reminders and updates for …". The reminder's link could match it.src/jobs/notify-rules.ts:77:TOLD_EVENTSaddsslot.change_sent, so the lookup no longer matches the partial indexactivity_commitment_lookup(src/db/concurrent-indexes.ts), which covers onlyreminder.sentandcommitment.confirmation_sent. It falls back toactivity_by_signup_occurred, which is bounded per signup, so this is fine at today's sizes. Worth addingslot.change_sentto that index if the scan ever shows up as slow.src/jobs/worker.ts:36:reminders.sendandslot-changes.sendhave separate pg-boss pollers, so one worker can run a reminder job and a change job for the same commitment at the same moment. Both readtoldDifferentTimebefore either records, and both send. That needs a slot moved to just past the 24h lead, with both scans coming due on the same tick, so it's rare. CLAUDE.md's "one worker, sends one at a time" guarantee no longer quite holds within a single process, though.src/jobs/reminders.ts:36: the reminder scan has no settle period. A slot inside the 24h lead that is edited over several saves can get a "Time changed" reminder for an in-between time, then a change email for the final one. Before this PR, the reminder re-arming for each time already did much the same, so this isn't a regression.- From the tophat: a move made while reminders are off is emailed if reminders are turned back on within 7 days. The editor note ("they won't be told if you change it") and the help line ("nobody is told when you move a slot") say less than that. It's the owner's call whether that's the behaviour they want, and whether the wording should say "while reminders are off".
Earlier threads: none. Copilot couldn't review because its quota is used up.
Checks: CI is green on 882ec37 (lint-type-test, e2e, CodeQL). The tophat passed on 882ec37, the current head. I read the full diff against main, rebuildSlotAt / syncAnchorAndSlotAt and the partial index, and ran a high-effort code review whose findings I checked by hand. I dropped one: the editor's count is spots, not people, which the PR description already calls out as the issue's choice. Checklist: the activity writes for moves are inside the slot transaction, the new events are in ACTIVITY_EVENTS and docs/telemetry.md, there's no new column, no migration and no new client route, and the legal page is updated with a new date.
Why owner, once fixed: pipeline:ux. It adds a new participant email ("Time changed: …") and a note in the slot editor, and it changes the unsubscribe page, the privacy page and the help article. See the tophat's 567-head-editor-reminders-on, 567-head-editor-reminders-off, 567-head-email-phone and 567-head-phone-unsubscribe.
https://claude.ai/code/session_01YURJFhAiKJ5eyih7mdpNF4
Generated by Claude Code
…ed slot A slot moved in the last week and then re-timed by a field change (switching the reminder date field, deleting the time field) passed every due rule, so everyone holding it heard the slot had moved. The scan now also requires the slot to still be where its newest move put it. Also puts the reminderSubject comment back on reminderSubject, reworded for "last emailed a different time", and corrects the reminder template's unsubscribe prop doc. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ps7eQtsKz9CUWmHc2qs835
|
Here's how I handled the six optional points from the round 1 review, in
Generated by Claude Code Generated by Claude Code |
Tophat: passedI ran 3f70bef locally with
Notes, not failures:
For the owner (UX): participants now get a "Time changed: · " email about 10 minutes after the organizer last moves their slot's date or time. It says "Your slot has changed", lists every field, and has the manage and unsubscribe links. In the builder, a slot with sign-ups shows a blue note saying how many people are signed up and whether they'll be emailed if it moves. A changed-time reminder now says "since we last emailed you". The unsubscribe page, the help article and the privacy page say the reminders switch and the unsubscribe link cover these emails too. See Screenshots are in https://claude.ai/code/session_011Tt4FkAMnhMJwsj4djzvdH. Generated by Claude Code |
richshaw
left a comment
There was a problem hiding this comment.
Review: passed (round 2)
Since round 1 (at 882ec37), 3f70bef fixes the one blocking finding. It adds a due rule, 'slot re-timed by a field change', to slotChangeDueRules in src/jobs/slot-changes.ts: the newest slot.rescheduled for the slot must have a to equal to slots.slot_at, cut to milliseconds. Because the scan and the send job both use the same rule set, a rebuildSlotAt re-time after a recent move now sends nothing, and skipReasons logs it by name. to is written with toISOString() in updateSlot, so the comparison is exact at millisecond precision, and a move to an undated slot (to null) fails the rule, which is right. The new DB test moves the slot, lets the change email go out, then switches reminderFromFieldRef to a second date field through updateSignup. It checks that slot_at moved and that the scan selects nothing. The reminder-lead helper now shifts the moves' to along with slot_at, so the "no change email follows" tests still pass for the right reason. The round 1 optionals on subjects.ts and reminder.tsx were taken. The rest were answered on the PR. The rest of the diff is what round 1 reviewed.
Blocking (0): none.
Optional (2):
src/jobs/slot-changes.ts:62: the newest move is picked byoccurred_at, which defaults tonow(), so it's the transaction's start time. If two saves of the same slot overlap, the later commit can carry the earlieroccurred_at. Then the "newest" move'stoisn't where the slot ended, and that slot's change email is held until its next move. Slot updates lock the row, so this needs two saves of one slot within milliseconds of each other. Leave it as is.src/jobs/slot-changes.ts:36: one line of the reworded JSDoc runs to about 110 characters. Prettier doesn't reflow comments. Rewrap it if the file is touched again.
Earlier threads: all resolved (the re-time thread, answered and fixed in 3f70bef). Copilot couldn't review because its quota is used up.
Checks: CI is green on 3f70bef (lint-type-test, e2e, CodeQL). The tophat passed on 3f70bef, the current head, and it let the worker's own crons send every email. I read the diff since round 1, moveOfThisSlot, the slot.rescheduled write in updateSlot, and the activity schema's occurred_at default.
Why owner: pipeline:ux. Participants get a new "Time changed: · " email about 10 minutes after the organizer last moves their slot's date or time. The slot editor shows a note when a slot has sign-ups. The unsubscribe page, the privacy page (dated 23 October 2026) and the help article mention the change emails. A changed-time reminder now says "since we last emailed you". One open question is yours, from both tophats: a move made while reminders are off is emailed if reminders are turned back on within 7 days. Do you want that, and should the editor note ("they won't be told if you change it") and the help line say "while reminders are off"? See the latest tophat's 567-head-editor-reminders-on, 567-head-editor-reminders-off, 567-head-phone-editor and the two unsubscribe screenshots, plus the first tophat's 567-head-email-phone.
https://claude.ai/code/session_01Bk2pWM6VExQLT1HFZyC4d4
Generated by Claude Code
"If you change its date or time, they'll get an email" now reads "we'll email them to let them know", and with reminders off "we won't email them if you change it", rather than "they won't be told". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DP1nWWH41t7Xjtf862UmoN
Tophat: passedRan bf22b52 locally with
Not re-checked: the change email itself and everything else in the PR, which this commit doesn't touch. The For the owner (UX): the note now says what we'll do ("we'll email them to let them know", "we won't email them") rather than what happens to them ("they'll get an email", "they won't be told"). You asked for this wording and approved it in the session below. Screenshots are in https://claude.ai/code/session_01DP1nWWH41t7Xjtf862UmoN. Generated by Claude Code |
|
Approved by the owner at The owner approved this code in a Claude Code session after asking for the friendlier note wording in Generated by Claude Code |
|
Not merged yet. Sent back to Gates that failed at
Every other gate passed. CI was green on https://claude.ai/code/session_01PtqmJDYFWZ87GsyZe2Ay2u Generated by Claude Code |
richshaw
left a comment
There was a problem hiding this comment.
Review: passed (round 3)
Round 2 passed 3f70bef. Since then the PR has one commit of its own, bf22b52, plus a merge from main (ead50d6) that resolved no conflicts (git show --cc lists no files). bf22b52 only rewords the slot editor's sign-up note, as the owner asked: "If you change its date or time, we'll email them to let them know." with reminders on, and "Reminders are off, so we won't email them if you change it." with reminders off. Both strings in SlotEditor.tsx and their two SlotEditor.test.tsx assertions changed together. I searched src, tests, docs, the skills and CHANGELOG for the old wording ("they'll get an email", "they won't be told"), and nothing is left. The CHANGELOG line ("whether they'll be told if it moves") still describes the note correctly. The rest of the diff is what rounds 1 and 2 reviewed.
Blocking (0): none.
Optional (0): none new. Round 2's two optional notes still stand. So does the owner question from both tophats: a move made while reminders are off is emailed if reminders come back on within 7 days, and the help line create-and-publish-a-signup.tsx:205 ("nobody is told when you move a slot") says less than that. The owner approved bf22b52 with that behaviour in place.
Earlier threads: all resolved.
Checks: CI is green on ead50d6 (lint-type-test, e2e, CodeQL). The tophat passed on bf22b52, and no runtime code has changed since (the shared check prints 0). I read the diff since 3f70bef and checked the merge commit's resolution.
Why owner, and why it goes to merge: pipeline:ux (a new participant email, the editor note, and changes to the unsubscribe, privacy and help pages). The owner's approval at bf22b52 (recorded above) covers this code, since the PR has no commits of its own after it, only a merge from main. It carries over, so this goes to pipeline:merge. Note for the merge stage: #565 has just landed and also touches SlotEditor.tsx. A test merge comes out clean, but the branch is behind main, so it will need an update and a fresh CI run.
https://claude.ai/code/session_01QT55BacbKh9sMx6AUmN8bU
Generated by Claude Code
Resolves conflicts with #565 and #567 in SlotEditor, WysiwygSlot, the SlotEditor tests and CHANGELOG by keeping both sides: the editor takes focusOnOpen/onFocused alongside the new taken/remindersOn props, and the focus ref sits on the field grid below the new signed-up note. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pfDqwVMpLjQtmFKGF5jBa
Resolves the CHANGELOG conflict with #567 by keeping both Added entries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MTuDgfKhQSncqThSL226TC
Fixes #524.
The problem
When an organizer changed a slot's date or time, nobody holding it heard about it until the next reminder, and their confirmation still showed the old time. With reminders off, they never heard at all. The slot editor in the Build tab didn't show how many people held the slot either, so an organizer could move one without knowing anyone was affected.
The fix
The email.
src/email/templates/slot-changed.tsx, subjectTime changed: <signup> · <slot>(slotChangedSubject). It prints every field throughSlotDetailsText, the participant's manage link, and an unsubscribe link with one-clickList-Unsubscribeheaders, as a reminder does.Recording moves. When
updateSlotchanges a slot'sslot_at, it now writes aslot.rescheduledrow ({ slotId, from, to }) in the same transaction. That covers the Build tab, group heading renames (oneupdateSlotper slot) and theupdate_slottool. Capacity-only edits and edits that keep the same date write nothing.Sending.
src/jobs/slot-changes.tsadds two queues:slot-changes.dispatch(cron every 2 minutes) andslot-changes.send(one job per commitment, retried like reminders). A commitment is due once three things hold:SLOT_CHANGE_SETTLE_MINUTES(10) without another moveThe send job asks all of that again before it sends and records
slot.change_sentwith theslotAtit told them. So quick edits send one email for where the slot ended up, a move and a move back send nothing, and a second job finds nothing to do."Last told". This is
toldDifferentTimeinsrc/jobs/notify-rules.ts. It's the newest of the participant's confirmation, reminders and change emails, read from the activity log. A confirmation (or a reminder from before reminders named their time) has noslotAt, so its time is thefromof the firstslot.rescheduledafter it. A reminder.sent with noslotAttherefore counts as "told the time the slot had then", as the issue asks.Shared rules. The reminder scan's rules for who may be emailed at all are now
notifiableRulesin the same file, and both scans spread them in:Turning reminders off or unsubscribing during the 10-minute wait is caught when the job runs (tested). The TypeScript
willSendReminderstays as it is: it is the confirmation's promise, not a scan.Reminders stay quiet.
changedon a reminder now means "the last email they got was for another time" (wasToldDifferentTime), instead of "an earlier reminder was for another time". After a change email, the reminder for that time reads as a normal "Reminder". The changed reminder's intro now says "since we last emailed you" instead of "after your last reminder", because the last email may have been the confirmation.Organizer warning.
SlotEditorshows a note above its fields whenever the slot has sign-ups:The issue's wording ("they'll get an email", "they won't be told") was made friendlier at the owner's request in
bf22b52. "Reminders on" isstate.reminderFieldRef !== null, so it switches as soon as the organizer flips the setting. The note's look came from the reminder-field note inInlineFieldForm. I extracted it assrc/components/ui/inline-note.tsx, and both use it.Copy updates. The unsubscribe page says it stops reminders and date or time change emails. The privacy page lists the fourth kind of email ("Last updated" moved to 23 October 2026). The reminders section of "Create and publish a signup" gets a paragraph that says the reminders setting covers these emails, using the imported constant. Also updated:
docs/telemetry.md(both new events), the Jobs paragraph in CLAUDE.md, and CHANGELOG.Decisions the issue didn't make:
slot_atchanges. The web process never talks to pg-boss today ("the dispatch scan is the scheduler", CLAUDE.md), and the request path would have needed a pg-boss client in bothupdateSlotcallers. Recording the move in the transaction and letting the worker's scan do the debounce gives the same emails, and nothing is lost if the web process dies after the commit. The cost is that emails go 10 to 12 minutes after the last change, not exactly 10.updateSlotmoves send.rebuildSlotAtalso re-times slots, when a time field is deleted or the reminder date field changes. Those aren't the organizer moving a slot, and they write noslot.rescheduled, so they send nothing. If one is followed by a real move, the email compares against the time the participant was last told, so it is still right.takenfromcommittedBySlot, as the issue says. Someone who took 2 spots counts as 2 "people".Tests
src/jobs/slot-changes.db.test.ts(new, 17 tests):fromandto, and capacity or same-date edits record nothingSlotEditor.test.tsx: no note without sign-ups, plus the singular/plural wording with reminders on and off.subjects.test.ts, the worker render script and its test cover the new template.pnpm lint && pnpm typecheck && pnpm knip && pnpm test(1350 tests, also onbf22b52)pnpm test:db(525 tests)pnpm build && pnpm budgets(builder 151.4 kB of 152)Generated by Claude Code