🤖 From Claude: filed at the maintainer's request as a follow-up to the events-IA refactor (PR #2113).
Summary
The per-event Roster page and the cross-event Attendees index render the same shared partials (app/views/events/_registrant_roster.html.erb, app/views/events/_registrant_breakdowns.html.erb) but are fed by two different data sources that each re-implement the same roster/breakdown method surface over a different population:
EventDashboard — one event's active registrants (registered + attended + incomplete_attendance + transferred_in), anchored to the event date.
AttendeesRoster / AttendeesBreakdowns — the cross-event population of people who attended a facilitator training, anchored to "now".
This is the deferred "Phase 4" of the IA work. The goal is to collapse the duplicated roster/breakdown logic behind a single population-parameterized service so there's one implementation, differing only in which people, via which registrations, as of when.
Why
- Duplication / drift risk.
AttendeesRoster/AttendeesBreakdowns were deliberately written to mirror EventDashboard's method names and return shapes so the shared partials read either one. Any change to a breakdown (a new dimension, a bug fix, a query optimization) must be made in two places or they silently diverge. The recent affiliations-N+1 fix, for example, had to be applied to EventDashboard and both Attendees* services separately.
EventDashboard is a god object (~1,300 lines, ~120 public methods) doing money, attendance, scholarships, roster maps, and breakdown datasets. The roster/breakdown concern is cleanly separable.
- The "which people / as of when" difference is currently implicit and hand-rolled in each service, rather than an explicit, testable property.
Current state (files)
app/services/event_dashboard.rb — roster maps (registrants, organization_ids_by_registrant, primary_sector_names_by_registrant, scholarship_by_recipient, program_statuses_by_registrant, location_label_by_registrant, ce_registration_by_registrant, …) and breakdown datasets (primary_sectors/sector_counts, age_groups, state_counts, country_counts, program_status_counts/program_status_by_organization/program_status_registrant_ids, life_experiences, settings, registrant_city_breakdown, organizations, scholarship/CE pies, and the *_registrant_ids_by_* drill-in maps).
app/services/attendees_roster.rb — the roster-map subset over a people collection (attended-training registrations).
app/services/attendees_breakdowns.rb — the breakdown-dataset subset over a people collection.
- Shared consumers:
_registrant_roster.html.erb, _registrant_breakdowns.html.erb, _breakdown_card.html.erb, _registrant_city_row.html.erb, _registrant_city_breakdown.html.erb.
- Controller:
EventsController#roster (feeds EventDashboard, context: :event) and EventsController#attendees (feeds Attendees*, context: :index).
Proposed design
Introduce three POROs (names from the original plan; open to alternatives):
RegistrantPopulation — the single source of "which people, via which registrations, as of when." Constructors for each context, e.g.
RegistrantPopulation.for_event(event) → the event's active registrations, reference_date = event start.
RegistrantPopulation.attended_trainings(people_scope) → attended facilitator-training registrations, reference_date = today.
Owns the shared, date-anchored primitives currently duplicated: city_by_organization, program_status_by_organization, org/person id maps, the reference-date anchoring, and the org-affiliation preload.
RegistrantRoster.new(population) — every per-registrant lookup map _registrant_roster reads. Replaces AttendeesRoster + EventDashboard's roster maps.
RegistrantBreakdowns.new(population) — every chart dataset + *_registrant_ids drill-in map _registrant_breakdowns reads (including the pie denominators currently computed in ERB). Replaces AttendeesBreakdowns + EventDashboard's breakdown methods.
EventDashboard keeps money / attendance / scholarship-money / applicants / shout-outs, composes a RegistrantPopulation.for_event, and delegates the roster/breakdown method names so dashboard.html.erb / recipients.html.erb / roster.html.erb don't change.
Tasks / scope
Risks / notes
- Behavior-preserving refactor. The per-event Roster and cross-event Attendees request/view specs are the safety net — their output must be unchanged. This is the main reason it was deferred:
EventDashboard is large and the roster/breakdown extraction touches many methods.
- Watch the subtle sourcing difference already encoded today: on the per-event Roster the scholarship/CE/org columns come from this event's registrations; on the cross-event index they come from each person's attended-training registrations. The population abstraction must make "which registrations" an explicit property so both are expressed without branching in the views.
- The
context: :event vs context: :index drill-in branching in _registrant_breakdowns.html.erb can likely be simplified once the population owns the id maps, but that's optional polish.
Out of scope
- No DB/schema changes.
- No UI/behavior changes — pure internal consolidation.
- No policy changes.
Acceptance criteria
AttendeesRoster and AttendeesBreakdowns are gone; one roster service + one breakdown service back both the per-event Roster and the cross-event Attendees index.
- The per-event Roster, cross-event Attendees, dashboard, and recipients pages render identically (existing request/view specs pass unchanged).
- Adding a new breakdown dimension requires editing exactly one place.
References
🤖 From Claude: filed at the maintainer's request as a follow-up to the events-IA refactor (PR #2113).
Summary
The per-event Roster page and the cross-event Attendees index render the same shared partials (
app/views/events/_registrant_roster.html.erb,app/views/events/_registrant_breakdowns.html.erb) but are fed by two different data sources that each re-implement the same roster/breakdown method surface over a different population:EventDashboard— one event's active registrants (registered + attended + incomplete_attendance + transferred_in), anchored to the event date.AttendeesRoster/AttendeesBreakdowns— the cross-event population of people who attended a facilitator training, anchored to "now".This is the deferred "Phase 4" of the IA work. The goal is to collapse the duplicated roster/breakdown logic behind a single population-parameterized service so there's one implementation, differing only in which people, via which registrations, as of when.
Why
AttendeesRoster/AttendeesBreakdownswere deliberately written to mirrorEventDashboard's method names and return shapes so the shared partials read either one. Any change to a breakdown (a new dimension, a bug fix, a query optimization) must be made in two places or they silently diverge. The recent affiliations-N+1 fix, for example, had to be applied toEventDashboardand bothAttendees*services separately.EventDashboardis a god object (~1,300 lines, ~120 public methods) doing money, attendance, scholarships, roster maps, and breakdown datasets. The roster/breakdown concern is cleanly separable.Current state (files)
app/services/event_dashboard.rb— roster maps (registrants,organization_ids_by_registrant,primary_sector_names_by_registrant,scholarship_by_recipient,program_statuses_by_registrant,location_label_by_registrant,ce_registration_by_registrant, …) and breakdown datasets (primary_sectors/sector_counts,age_groups,state_counts,country_counts,program_status_counts/program_status_by_organization/program_status_registrant_ids,life_experiences,settings,registrant_city_breakdown,organizations, scholarship/CE pies, and the*_registrant_ids_by_*drill-in maps).app/services/attendees_roster.rb— the roster-map subset over apeoplecollection (attended-training registrations).app/services/attendees_breakdowns.rb— the breakdown-dataset subset over apeoplecollection._registrant_roster.html.erb,_registrant_breakdowns.html.erb,_breakdown_card.html.erb,_registrant_city_row.html.erb,_registrant_city_breakdown.html.erb.EventsController#roster(feedsEventDashboard,context: :event) andEventsController#attendees(feedsAttendees*,context: :index).Proposed design
Introduce three POROs (names from the original plan; open to alternatives):
RegistrantPopulation— the single source of "which people, via which registrations, as of when." Constructors for each context, e.g.RegistrantPopulation.for_event(event)→ the event's active registrations, reference_date = event start.RegistrantPopulation.attended_trainings(people_scope)→ attended facilitator-training registrations, reference_date = today.Owns the shared, date-anchored primitives currently duplicated:
city_by_organization,program_status_by_organization, org/person id maps, the reference-date anchoring, and the org-affiliation preload.RegistrantRoster.new(population)— every per-registrant lookup map_registrant_rosterreads. ReplacesAttendeesRoster+EventDashboard's roster maps.RegistrantBreakdowns.new(population)— every chart dataset +*_registrant_idsdrill-in map_registrant_breakdownsreads (including the pie denominators currently computed in ERB). ReplacesAttendeesBreakdowns+EventDashboard's breakdown methods.EventDashboardkeeps money / attendance / scholarship-money / applicants / shout-outs, composes aRegistrantPopulation.for_event, and delegates the roster/breakdown method names sodashboard.html.erb/recipients.html.erb/roster.html.erbdon't change.Tasks / scope
RegistrantPopulationwith both constructors and the shared date-anchored primitives; unit-spec the two anchorings explicitly (the reference-date behavior is currently only implicit).RegistrantRoster+RegistrantBreakdownsover a population; move the pie-denominator math out of_registrant_breakdowns.html.erbintoRegistrantBreakdowns.EventsController#attendeesat the new services; haveEventDashboardcompose + delegate.AttendeesRoster/AttendeesBreakdowns.RegistrantPopulationso it lives in one place.AGENTS.mdservices section.Risks / notes
EventDashboardis large and the roster/breakdown extraction touches many methods.context: :eventvscontext: :indexdrill-in branching in_registrant_breakdowns.html.erbcan likely be simplified once the population owns the id maps, but that's optional polish.Out of scope
Acceptance criteria
AttendeesRosterandAttendeesBreakdownsare gone; one roster service + one breakdown service back both the per-event Roster and the cross-event Attendees index.References