Skip to content

Chart and filter registrant locations by where they work - #2844

Open
maebeale wants to merge 10 commits into
mainfrom
maebeale/work-location-charts
Open

maebeale wants to merge 10 commits into
mainfrom
maebeale/work-location-charts

Conversation

@maebeale

@maebeale maebeale commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

🤖 suggested review level: 5 Inspect 🔬 changes what every registrant location chart and filter counts, plus the search grammar in every filter box

Registrant location charts and filters now use where people work (their registered org's address), falling back to where they live. Along the way, every search box in the portal ends up speaking the same language.

Where a registrant is

  • RegistrationLocations is the one source, per registration: org linked on the registration → the address picked on that registration's affiliation → the org's oldest active address → the one address that stands for the registrant
  • Address::STANDING — active work → latest-edited active personal → active other → inactive personal → inactive work
    • Work and personal swap below the active line on purpose: a current work address is the sharpest thing we know, a retired one the stalest
    • Written once in Ruby and compiled into the ORDER BY; AddressDecorator sorts its loaded association by the same list
    • Retired addresses count, so nobody falls off the map for having only a stale one
  • A row carries every field an address uses to say where it is — city, county, state, country, locality, zip, school district, the three LA districts

Filters

  • City/State/County/Country go in together through one located_in(**fields) / matching(**fields), so they describe one address
  • Attendees page: the City box becomes a prefixed "Where they work" box (city: austin locality: "Outside CA"), which is also what makes locality / zip / district / LA districts filterable at all
    • A visible dropdown wins over the same field typed in the box
    • A bare city param still works, now as a removable chip
  • Attendees filter dropdowns read the address tables directly (two grouped queries) instead of building a row per registration across the whole reportable population

One search grammar

Several words all have to match (was: either, on the people index)
-- either — now works in every box, including the roster keyword search
"..." phrase
city: etc. prefixes, on the contact and work-location boxes
  • Every box carries the same help icon, generated from the same constants
  • New prefixes: locality:, district:, la_council:, la_supervisorial:, la_spa:, and type: for work/personal/mailing — SearchCop ANDs prefixes onto one joined row, so type: personal city: austin is how you search a personal city alone
  • Search reads live addresses only. The standing chain is right for showing someone, wrong for finding them — an admin holding an old address would be told nobody lives there. It also costs a correlated subquery per person where a plain join does now
  • Organizations index Address box joins the shared search (keeps its LA district columns as integers; commas become spaces so a pasted "Los Angeles, CA" still reads as two words)
  • Person.search_by_params(contact_info:) deleted — every index reaches ContactSearchable from the URL param, so it was a second, older meaning for the same word

Split out of #2842 (org-answer refactor). Label/doc wording follow-up in #2845.

Features seed (optional)

  • Added a features seed (optional)

🤖 Generated with Claude Code

Comment thread app/models/event_registration.rb Outdated
.where(addresses: { inactive: false, state: state })
.distinct
# Where the registrant works on the registration (RegistrationWorkAddresses).
scope :work_state, ->(state) {

@maebeale maebeale Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 From Claude: These scopes pluck the current relation's ids and resolve locations in Ruby, so the work-else-home rule lives only in RegistrationLocations. Fine at one event's scale; worth knowing if they get used on a large cross-event relation.

maebeale added a commit that referenced this pull request Oct 25, 2026
Keeps this PR to the org-answer refactor so each reviews on its own.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@maebeale
maebeale marked this pull request as ready for review October 25, 2026 11:46
Comment thread app/models/event_registration.rb Outdated
# otherwise match a city from one and the state from the other. County accepts
# "STATE|County" (from the roster filter) to scope to a county within a specific
# state, or a bare county name for backward compatibility; a county state that
# contradicts the State filter can match nothing.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 From Claude: City, state and county deliberately arrive in one call rather than as chainable scopes: chained, each one independently matched any of the registration's location rows, so a registrant registered under orgs in Portland OR and Seattle WA matched City=Seattle + State=OR (the old shared addresses join ruled that out). A County value whose state contradicts the State filter resolves to none for the same reason.

maebeale added a commit that referenced this pull request Oct 25, 2026
* Build org-answer entries in one place

The org name/position/website/type/address answers were shaped into the same
hash in four places, so every new org field (like country) had to be added to
each one and they could drift. FormSubmission.org_entry_from now builds it, and
ProfileDiff.for_entry reads it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Count org-entry identifiers as read by the submission pipelines

CaptureFromSubmission now reads org answers through FormSubmission::ORG_ENTRY_FIELD_IDENTIFIERS
rather than literal field_value calls, so the coverage spec's scrape missed them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Chart registrant locations by where they work

Admins want the background charts to show where registrants work, not where
they live. Cities, states, and countries now read the org linked on the
registration, at the address picked on that registration's affiliation, else
the org's oldest address. The drill-ins follow so a clicked row lists the same
people; the roster's State/County filters keep matching home addresses.

Also builds org_entry_from off the field-map constants so the identifier list
and the hash can't drift.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Filter registrants by where they work

The location filters should mean the same thing as the location charts. The
roster, bulk-email picker, and attendees State/County/City filters now match
the registration's work address (RegistrationWorkAddresses), so the attendees
States drill-in can reuse the state param.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Move the work-location charts and filters to #2844

Keeps this PR to the org-answer refactor so each reviews on its own.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Read the org fields in one query, and keep the coverage guard honest

Building the entry up front meant the form-params path looked up each org
field on its own, so a form with no org section paid nine queries for
answers it never had. One batched lookup covers them all.

The identifier-coverage guard counted the appended constants too, which
left it passing on a scan that had stopped matching most of the pipeline —
it now counts only what it scanned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
maebeale and others added 6 commits October 25, 2026 08:23
Admins want the location charts and the State/County/City filters to show
where registrants work, not where they live. Both now read the org linked on
the registration, at the address picked on that registration's affiliation,
else the org's oldest address (RegistrationWorkAddresses). Drill-ins follow so
a clicked row lists the same people.

Moved out of #2842 so it reviews separately from the org-answer refactor.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A registrant with no linked org (or an org with no address) dropped out of the
location filters entirely; counting them where they live keeps them findable.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With the fallback to a registrant's own address, "work" no longer describes the
whole rule: RegistrationWorkAddresses -> RegistrationLocations and work_* scopes
-> located_in_*.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The three chained location scopes each tested any of a registration's
addresses, so a registrant working in two states matched a city from one and
the state from the other — the shared addresses join used to rule that out.
One `located_in(state:, county:, city:)` call makes the invariant structural,
and the reminder picker's City search now runs through it instead of matching
rows the State and County dropdowns had already filtered separately.

RegistrationLocations also takes a relation now. The attendees index built its
filter options by plucking every reportable registration id and feeding the
array back as IN lists across five queries; as subqueries that stays one
statement no matter how large the population.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review of the work-location branch turned up three things. The attendees index
resolved County in a second RegistrationLocations pass, so City and County could
land on different addresses — the exact case located_in was built to prevent, and
the one the feature entry promises is impossible. Its State and County dropdowns
were filled by building a location row for every registration in the viewer's
whole reportable population, where two grouped queries over the address tables
answer the same question. And rows_by_registration had no callers.

Rows now carry every field an address uses to say where it is — everything but
the street and phone — so the next chart, count or filter asks for a field name
rather than another method: registrant_ids_by(:country) replaces a hand-written
country map, and locality, zip, school district and the LA districts come along
for free. matching takes the whole filter set in one call, and filters_from folds
a County option's own state into it so the roster scope and the index controller
stop keeping separate copies of that rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@maebeale
maebeale force-pushed the maebeale/work-location-charts branch from 27478d7 to c1e6a7e Compare October 25, 2026 12:31
maebeale and others added 4 commits October 25, 2026 08:39
…dexes

The attendees page searched contact info through the old catch-all `search`
scope: no prefixes, and no county or country. It now uses the box Justin built
for the people index, so "city: austin" means the same thing on both pages and a
prefix added once shows up in both. Its main box becomes Name or email to match,
and the prefixed box sits in the drawer behind the same super-admin gate the
people index uses.

The prefix map now lives in ContactSearchable rather than in Person, and the help
text beside each box is built from it, so the two can't drift. Locality and
school district join the list: an address says where someone is with every field
but the street, and a search that stops short of one is a search someone retries
by hand.

Labelled "Their own contact info" on the attendees page on purpose — the City,
State and County filters beside it read where a registrant works, and the two
answering different questions is worth saying out loud rather than discovering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An event owner reaches this page without being an admin, and the box reads a
person's own address, so assert it isn't rendered for them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things, all about saying the same thing the same way twice.

Address.ranked_for_person settles which of a person's addresses speaks for them
when only one can: their active work address, else the most recently edited
active personal one, else a retired personal, else a retired work. Work and
personal swap places below the active line because a current work address is the
sharpest thing we know and a retired one the stalest — people change jobs, home
outlasts the job. RegistrationLocations uses it instead of the primary flag, and
stops requiring the address to be active: a registrant with nothing but a retired
address belongs on the map at that address rather than nowhere.

"--" has meant "either" everywhere in this app except the two newest search boxes
and the roster keyword box, which each meant something else. Now every box takes
it, every box carries the same hint saying so, and the prefixed boxes still take
SearchCop's own `or` on top. The roster keyword box asks its whole OR block per
alternative; the SearchCop boxes rewrite "--" into `or` on the way in.

A `type:` prefix narrows the contact search to a work, personal or mailing
address. SearchCop ANDs the prefixes onto one joined row, so `type: personal
city: austin` asks for an address that is both — one prefix instead of a parallel
personal_* set, and it composes with every field there is.

Person.search_by_params no longer answers contact_info: the people and attendees
indexes both reach ContactSearchable through the URL param directly, so the
branch was a second, older meaning for the same word. Its specs now ask the scope
the URL actually hits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…here

Searching someone up now reads their live addresses only. The standing chain
settles which address SHOWS a person when only one can; it is the wrong rule for
finding them, because the admin holding a sticky note with their old address
would search it and be told nobody lives there. Expressing the chain in a search
also costs a correlated subquery per person where a plain join on an indexed key
does now — slower, to answer worse. Live addresses, all of them.

The chain itself moved into Address::STANDING, written once in Ruby and compiled
into the ORDER BY, because a hand-kept SQL twin drifts the first time the order
changes. AddressDecorator sorts its loaded association by the same list instead
of its own near-copy.

Several words now narrow rather than widen. "amy jones" meant everyone called
either, which is not what anyone types it for; it means the one person. That
leaves "--" as the single way to say "either", which is what it has always meant
everywhere else in this app.

The organizations index Address box joins the shared search. It kept the LA
district columns, which SearchCop can match as the integers they are so long as a
bare word never asks them for a LIKE — they sit in SCOPING_PREFIXES with type:.
Commas become spaces on the way in so a pasted "Los Angeles, CA" still reads as
two words. search_contact distincts itself rather than trusting four call sites
to remember.

The attendees page had two City filters meaning different things side by side.
The work-location one is now a prefixed box of its own, "Where they work", read
by RegistrationLocations.parse_query — which is also what lets locality, zip,
district and the LA districts be filtered at all. A visible dropdown still wins
over the same field typed in the box, and a bare city param keeps working as a
chip so an older link narrows visibly rather than silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant