Skip to content

Commit 9931f47

Browse files
Merge pull request #237 from CodeForPhilly/fix/rsvp-feedback
fix(web): clearer attendee groups and a visible Cancel RSVP button
2 parents dc8b5e8 + 399eb4e commit 9931f47

6 files changed

Lines changed: 101 additions & 31 deletions

File tree

‎apps/web/src/lib/event-attendees.ts‎

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,15 @@ export interface AttendeeGroup {
1010
}
1111

1212
/**
13-
* Projects first (by attendee count, then title), then "New to Code for
14-
* Philly", "Looking for a project", "Not sure yet" — each only when
15-
* non-empty. When nobody has answered, one unlabelled group.
13+
* Members who haven't answered first, unlabelled (any label reads as doubt
14+
* about attending); then projects (by attendee count, then title), then "New
15+
* to Code for Philly", "No project yet" — each only when non-empty.
1616
*/
1717
export function groupAttendees(rsvps: EventRsvp[]): AttendeeGroup[] {
18-
if (rsvps.every((r) => r.plan === null)) {
19-
return rsvps.length ? [{ key: 'all', label: null, people: rsvps }] : [];
20-
}
2118
const projects = new Map<string, AttendeeGroup>();
2219
const newcomers: EventRsvp[] = [];
2320
const looking: EventRsvp[] = [];
24-
const unsure: EventRsvp[] = [];
21+
const unanswered: EventRsvp[] = [];
2522
for (const r of rsvps) {
2623
if (r.plan === 'project' && r.project) {
2724
const g = projects.get(r.project.slug) ?? {
@@ -34,13 +31,16 @@ export function groupAttendees(rsvps: EventRsvp[]): AttendeeGroup[] {
3431
projects.set(r.project.slug, g);
3532
} else if (r.plan === 'newcomer') newcomers.push(r);
3633
else if (r.plan === 'no_project') looking.push(r);
37-
else unsure.push(r);
34+
else unanswered.push(r);
3835
}
39-
const groups = [...projects.values()].sort(
36+
const groups: AttendeeGroup[] = unanswered.length
37+
? [{ key: 'unanswered', label: null, people: unanswered }]
38+
: [];
39+
const byProject = [...projects.values()].sort(
4040
(a, b) => b.people.length - a.people.length || (a.label ?? '').localeCompare(b.label ?? ''),
4141
);
42+
groups.push(...byProject);
4243
if (newcomers.length) groups.push({ key: 'newcomer', label: 'New to Code for Philly', people: newcomers });
43-
if (looking.length) groups.push({ key: 'no_project', label: 'Looking for a project', people: looking });
44-
if (unsure.length) groups.push({ key: 'unsure', label: 'Not sure yet', people: unsure });
44+
if (looking.length) groups.push({ key: 'no_project', label: 'No project yet', people: looking });
4545
return groups;
4646
}

‎apps/web/src/screens/EventDetail.tsx‎

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -462,13 +462,9 @@ export function EventDetail() {
462462
<>
463463
<p className="flex flex-wrap items-center gap-3">
464464
<span className="text-lg font-semibold text-green-700">You&rsquo;re going ✓</span>
465-
<button
466-
type="button"
467-
className="text-sm text-muted-foreground hover:underline"
468-
onClick={() => setWithdrawOpen(true)}
469-
>
470-
Can&rsquo;t make it?
471-
</button>
465+
<Button variant="outline" size="sm" onClick={() => setWithdrawOpen(true)}>
466+
Cancel RSVP
467+
</Button>
472468
</p>
473469
<FollowUp
474470
plan={event.viewerRsvp?.plan ?? null}
@@ -614,15 +610,15 @@ export function EventDetail() {
614610
<Dialog open={withdrawOpen} onOpenChange={setWithdrawOpen}>
615611
<DialogContent>
616612
<DialogHeader>
617-
<DialogTitle>Withdraw your RSVP?</DialogTitle>
613+
<DialogTitle>Cancel your RSVP?</DialogTitle>
618614
<DialogDescription>You&rsquo;ll be removed from the attendee list for “{event.title}”.</DialogDescription>
619615
</DialogHeader>
620616
<DialogFooter>
621617
<Button variant="outline" onClick={() => setWithdrawOpen(false)} disabled={rsvpBusy}>
622618
Keep my RSVP
623619
</Button>
624620
<Button variant="destructive" onClick={() => void doWithdraw()} disabled={rsvpBusy}>
625-
{rsvpBusy ? 'Withdrawing…' : "I can't make it"}
621+
{rsvpBusy ? 'Cancelling…' : 'Cancel RSVP'}
626622
</Button>
627623
</DialogFooter>
628624
</DialogContent>

‎apps/web/tests/EventDetail.test.tsx‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ describe('EventDetail', () => {
123123
expect(screen.getByRole('button', { name: 'Change' })).toBeInTheDocument();
124124
});
125125

126-
it('going with an answered follow-up shows the selected choice and "Can\'t make it?"', async () => {
126+
it('going with an answered follow-up shows the selected choice and "Cancel RSVP"', async () => {
127127
mockFetch({
128128
me: USER_ME,
129129
event: makeEvent({
@@ -133,7 +133,7 @@ describe('EventDetail', () => {
133133
render();
134134
expect(await screen.findByText('You’re going ✓')).toBeInTheDocument();
135135
expect(screen.getByText('I’m coming to work on SquadQuest')).toBeInTheDocument();
136-
expect(screen.getByRole('button', { name: 'Can’t make it?' })).toBeInTheDocument();
136+
expect(screen.getByRole('button', { name: 'Cancel RSVP' })).toBeInTheDocument();
137137
});
138138

139139
it('project picker: opened empty with nobody named yet shows only the hint; typing searches and Enter saves', async () => {
@@ -208,7 +208,7 @@ describe('EventDetail', () => {
208208
expect(screen.getByRole('link', { name: '14 going' })).toBeInTheDocument();
209209
});
210210

211-
it('groups attendees: projects by count, then newcomers, looking, not sure', async () => {
211+
it('groups attendees: unanswered first and unlabelled, then projects by count, newcomers, no project', async () => {
212212
mockFetch({
213213
event: makeEvent({ rsvpCount: 6 }),
214214
rsvps: [
@@ -227,7 +227,10 @@ describe('EventDetail', () => {
227227
const headings = within(attendees)
228228
.getAllByRole('heading', { level: 3 })
229229
.map((h) => h.textContent);
230-
expect(headings).toEqual(['Zeta', 'Alpha', 'New to Code for Philly', 'Looking for a project', 'Not sure yet']);
230+
expect(headings).toEqual(['Zeta', 'Alpha', 'New to Code for Philly', 'No project yet']);
231+
const firstList = within(attendees).getAllByRole('list')[0]!;
232+
expect(within(firstList).getByLabelText('Fay')).toBeInTheDocument();
233+
expect(within(firstList).queryByLabelText('Ann')).not.toBeInTheDocument();
231234
expect(within(attendees).getByRole('link', { name: 'Zeta' })).toHaveAttribute('href', '/projects/zeta');
232235
});
233236

‎plans/rsvp-feedback-labels.md‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
status: done
3+
pr: 237
4+
depends: [events-core]
5+
specs:
6+
- specs/behaviors/events.md
7+
- specs/screens/event-detail.md
8+
issues: []
9+
---
10+
11+
# Plan: Attendee groups and the cancel control, from launch feedback
12+
13+
## Scope
14+
15+
Early feedback in #website-relaunch on the November hack night page:
16+
17+
- A member listed under **Not sure yet** read it as doubt about attending
18+
(it meant "hasn't said what they're coming to do"); a second organizer
19+
found it unclear too.
20+
- A member asked for a way to say they're not going: the existing quiet
21+
"Can't make it?" link next to "You're going ✓" went unseen.
22+
- A member asked for "Maybe"; decided against it (an unsure member says
23+
yes, adds it to their calendar, and cancels if plans change).
24+
25+
**In:** unanswered attendees listed first, unlabelled, directly under
26+
"Going (N)"; "Looking for a project" renamed "No project yet" to mirror the
27+
choice "I don't have a project yet"; "Can't make it?" becomes an outline
28+
"Cancel RSVP" button with a "Cancel your RSVP?" dialog; the no-maybe
29+
rationale extended in the spec.
30+
31+
**Out:** "Maybe"; the email copy ("Can't make it? Update your RSVP…"),
32+
which already points at the page.
33+
34+
## Implements
35+
36+
- [behaviors/events.md](../specs/behaviors/events.md#rsvp) — RSVP and the
37+
follow-up groups.
38+
- [screens/event-detail.md](../specs/screens/event-detail.md) — RSVP block,
39+
attendee list, Withdraw action.
40+
41+
## Approach
42+
43+
1. `groupAttendees` puts the unanswered first with `label: null` (the
44+
renderer already skips the heading for an unlabelled group); the
45+
all-unanswered special case falls out of that.
46+
2. Swap the text button for a shadcn outline `Button`; reword the dialog.
47+
48+
## Validation
49+
50+
- [x] `npm run type-check && npm run lint && npm test` clean.
51+
- [x] On an event with mixed answers (component test; devbox has one RSVP), the unanswered avatars sit directly
52+
under "Going (N)" and the labelled groups follow.
53+
- [x] Signed in and going, "Cancel RSVP" shows beside "You're going ✓" and
54+
cancels after the dialog.
55+
56+
## Risks / unknowns
57+
58+
- None.
59+
60+
## Notes
61+
62+
- Feedback from Kat Jost and Travis Southard in #website-relaunch after
63+
the events launch (2026-10-14). Chris decided: no "Maybe" for now, and
64+
the unanswered shouldn't trail the list where they'd look like members
65+
of the last labelled group.
66+
- Checked signed in on the devbox build: "Cancel RSVP" beside "You're
67+
going ✓"; the dialog opens with "Keep my RSVP" focused.
68+
69+
## Follow-ups
70+
71+
- **None.**

‎specs/behaviors/events.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ An event may carry an **agenda**: an ordered list of time slots within that one
7676

7777
## RSVP
7878

79-
An RSVP is a public statement by a signed-in member that they're coming. It is **binary**: an `EventRsvp` record exists (going) or it doesn't. There is no "maybe" and no stored "not going" — withdrawing an RSVP deletes the record. Meetup dropped "maybe" years ago for the same reason: it tells organizers nothing.
79+
An RSVP is a public statement by a signed-in member that they're coming. It is **binary**: an `EventRsvp` record exists (going) or it doesn't. There is no "maybe" and no stored "not going" — withdrawing an RSVP deletes the record. Meetup dropped "maybe" years ago for the same reason: it tells organizers nothing. Someone unsure says yes, adds the event to their calendar, and cancels if plans change; cancelling is one visible button on the event page, and the event description asks people to cancel so the space goes to someone else.
8080

8181
### Who can RSVP
8282

@@ -97,11 +97,11 @@ RSVP records live in the public data repo and the attendee list on the event pag
9797
| Choice | `EventRsvp.plan` | Attendee list group |
9898
| ------ | ---------------- | ------------------- |
9999
| "I'm new to Code for Philly" | `newcomer` | **New to Code for Philly** |
100-
| "I don't have a project yet" | `no_project` | **Looking for a project** |
100+
| "I don't have a project yet" | `no_project` | **No project yet** |
101101
| "I'm coming to work on …" + project picker | `project` + `projectId` | the project |
102-
| *(unanswered)* | absent | **Not sure yet** |
102+
| *(unanswered)* | absent | *(no heading — listed first)* |
103103

104-
The answer can be changed any time while the RSVP window is open and never sends mail. The point is organizational, not gatekeeping: the attendee list groups going members by project (ordered by attendee count, then title), then **New to Code for Philly**, then **Looking for a project**, then **Not sure yet**. Organizers see at a glance who to welcome at the door and which tables will be full; newcomers see that they won't be the only new face. This is what laddr's check-in-by-project did well, with the newcomer case made explicit.
104+
The answer can be changed any time while the RSVP window is open and never sends mail. The point is organizational, not gatekeeping: the attendee list shows going members who haven't answered first, directly under the "Going" heading with no subheading of their own, then groups the rest by project (ordered by attendee count, then title), then **New to Code for Philly**, then **No project yet**. Group names mirror the choice the member clicked; the unanswered get no label because any label ("Not sure yet", "Undecided") reads as doubt about attending, and listing them first keeps them from looking like members of the last labelled group. Organizers see at a glance who to welcome at the door and which tables will be full; newcomers see that they won't be the only new face. This is what laddr's check-in-by-project did well, with the newcomer case made explicit.
105105

106106
### Side effects of RSVPing
107107

‎specs/screens/event-detail.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ Across the top of the content area, before the title:
4444
| ------------ | -------- |
4545
| Anonymous, RSVPs open | Button "Sign in to RSVP" → `/login?return=/events/<slug>`; helper "Your name will appear on the attendee list." |
4646
| Signed in, not going, RSVPs open | Primary button "I'm going"; same helper text |
47-
| Signed in, going, RSVPs open | Success state "You're going ✓" with a quiet "Can't make it?" link that withdraws (confirm); below it the **follow-up** per [behaviors/events.md](../behaviors/events.md#the-follow-up-what-are-you-coming-to-do): the prompt "What are you coming to do? (optional)" with three choices — "I'm new to Code for Philly", "I don't have a project yet", "I'm coming to work on [Pick a project ▾]". The picker is a find-as-you-type combobox that never lists the whole catalog: opened with nothing typed, it offers two groups — "Your recent projects", the projects the member named on their own RSVPs to other events (`viewerRecentProjects`, newest first), then "Other active projects", the last ten distinct projects anyone named on any RSVP (`recentProjects`, newest first) — a project appearing once in the first group it belongs to, empty groups omitted, so a newcomer sees the teams that actually meet without searching; only when both are empty, nothing but the input's own hint "Type a project name"; once the member types, it searches projects by title and text (`GET /api/projects?q=`), showing at most eight in relevance order, or "No projects match". Arrow keys, Enter and Escape work as in any combobox. Selecting a choice saves immediately; the chosen one shows as selected with a "Change" affordance. Unanswered shows the three choices; never a blank. |
47+
| Signed in, going, RSVPs open | Success state "You're going ✓" with an outline "Cancel RSVP" button beside it that withdraws (confirm dialog "Cancel your RSVP?" with "Keep my RSVP" and "Cancel RSVP"); below it the **follow-up** per [behaviors/events.md](../behaviors/events.md#the-follow-up-what-are-you-coming-to-do): the prompt "What are you coming to do? (optional)" with three choices — "I'm new to Code for Philly", "I don't have a project yet", "I'm coming to work on [Pick a project ▾]". The picker is a find-as-you-type combobox that never lists the whole catalog: opened with nothing typed, it offers two groups — "Your recent projects", the projects the member named on their own RSVPs to other events (`viewerRecentProjects`, newest first), then "Other active projects", the last ten distinct projects anyone named on any RSVP (`recentProjects`, newest first) — a project appearing once in the first group it belongs to, empty groups omitted, so a newcomer sees the teams that actually meet without searching; only when both are empty, nothing but the input's own hint "Type a project name"; once the member types, it searches projects by title and text (`GET /api/projects?q=`), showing at most eight in relevance order, or "No projects match". Arrow keys, Enter and Escape work as in any combobox. Selecting a choice saves immediately; the chosen one shows as selected with a "Change" affordance. Unanswered shows the three choices; never a blank. |
4848
| RSVPs closed (cancelled, past) | No control; the count line still renders |
4949

5050
Under the control: "14 going" linked to the attendee list anchor. When `meetupUrl` is set, a small muted "Also listed on Meetup ↗" link sits *below* everything in this block, never beside the button.
@@ -63,7 +63,7 @@ Across the top of the content area, before the title:
6363

6464
1. **Attendees** (`id="attendees"`)
6565
- Heading "Going (14)"
66-
- Grouped per [behaviors/events.md](../behaviors/events.md#the-follow-up-what-are-you-coming-to-do): a subheading per project (linked to `/projects/<slug>`) with that group's avatars (48px, name on hover, linked to `/members/<slug>`), then "New to Code for Philly", "Looking for a project", and "Not sure yet", each only when non-empty. When nobody has answered the follow-up, no subheadings — just the avatar grid.
66+
- Grouped per [behaviors/events.md](../behaviors/events.md#the-follow-up-what-are-you-coming-to-do): a subheading per project (linked to `/projects/<slug>`) with that group's avatars (48px, name on hover, linked to `/members/<slug>`), The members who haven't answered come first, as an avatar grid with no subheading directly under "Going (N)"; then a subheading per project (linked to `/projects/<slug>`) with that group's avatars (48px, name on hover, linked to `/members/<slug>`), then "New to Code for Philly" and "No project yet", each only when non-empty. When nobody has answered the follow-up, that first grid is the whole list.
6767
- Empty: "Nobody has RSVP'd yet. Be the first!" (or "Nobody RSVP'd." on past events)
6868
- For a cancelled event the heading reads "Had planned to go (14)"
6969

@@ -80,7 +80,7 @@ Across the top of the content area, before the title:
8080
| Action | Affects | Caused by |
8181
| ------ | ------- | --------- |
8282
| RSVP | `PUT /api/events/:slug/rsvp`; confirmation email | "I'm going" |
83-
| Withdraw | `DELETE /api/events/:slug/rsvp` | "Can't make it?" + confirm |
83+
| Withdraw | `DELETE /api/events/:slug/rsvp` | "Cancel RSVP" + confirm |
8484
| Answer / change the follow-up | `PUT /api/events/:slug/rsvp` with `plan` (+ `projectSlug`) | Follow-up choices |
8585
| Add to Google Calendar | Opens calendar.google.com template | Link |
8686
| Download .ics | Downloads the file | Link |

0 commit comments

Comments
 (0)