Skip to content

Add public input to the conference committee page - #2303

Merged
Mephistic merged 12 commits into
codeforboston:mainfrom
hyphacoop:conference-testimony
Oct 27, 2026
Merged

Mephistic merged 12 commits into
codeforboston:mainfrom
hyphacoop:conference-testimony

Conversation

@fastfadingviolets

@fastfadingviolets fastfadingviolets commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

People can now file testimony on a committee of conference with a four-way stance on which version should pass, read what others filed in Public Input, and add their own from the Add Your Input panel, while that testimony stays out of the canonical bill's page, counts, search and followers, and reaches the author's followers' digest and newsfeed as conference input. Existing testimony is backfilled with an explicit null conferenceId so bill testimony is filtered by query rather than in memory; the backfill has to run in each environment before and again after the new functions and hosting go live, as the last commit describes.

Closes hyphacoop#69
Closes hyphacoop#82
Closes hyphacoop#78
Closes hyphacoop#108
Closes hyphacoop#109
Closes hyphacoop#110
Closes hyphacoop#111

What's in it

To Deploy

Everything except for the backfill step is automated. The safest way to deploy with no downtime is:

  1. Before merging, run the backfill on dev. Main's code ignores the new field, so this is safe:
yarn firebase-admin run-script backfillTestimonyScope --env dev --dry-run
yarn firebase-admin run-script backfillTestimonyScope --env dev
  1. Merge. The workflow deploys indexes and functions.
  2. Run the backfill once more, to catch drafts and publications main's code wrote in between.

🤖 Generated with Claude Code

Testimony on a committee of conference answers a different question than
testimony on a bill: which version the committee should report, not whether
the bill should pass. Position gains passNothing, passSomething,
passSenateVersion and passHouseVersion. A draft with a conferenceId must take
one of them; every other draft keeps the three bill positions.

It follows ballot questions: one Testimony type scoped by an optional
conferenceId, with counts kept on the conference document. The testimony is
filed against the conference's canonical bill but kept off it: not in its
counts or latest testimony, not in testimony search, and not sent to the
bill's followers. The author's followers still see it.

A user can now hold a bill testimony and a conference testimony on one bill,
so publish, delete, drafts and the version archive match the scope in memory
instead of by query. That avoids stamping conferenceId: null on existing
documents. Excluding conference testimony from search changes the indexed
collection's filter, so each environment rebuilds the testimony collection
on its next deploy. One composite index is added for the conference listing.
Rolling back once conference testimony exists leaves older functions unable
to publish or delete it.

This is the server side only. Nothing under components/ or pages/ changes, so
there is no way to file conference testimony from the site yet.

Verified: functions and site tsc; functions unit tests for testimony and
notifications (32); the testimony, ballot question, moderation, conferences and
count backfill integration suites (59 tests), run on the branch tip where the
functions are unchanged from this commit.

Not verified: that testimony search leaves conference testimony out (no
Typesense locally); publishing on dev.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
maple-dev Ready Ready Preview Oct 27, 2026 3:12pm UTC

Request Review

@fastfadingviolets

Copy link
Copy Markdown
Contributor Author

This should go in after #2299

@fastfadingviolets fastfadingviolets changed the title Build the conference committee page: hero, bill texts and public input Add public input to the conference committee page Oct 21, 2026
@fastfadingviolets

Copy link
Copy Markdown
Contributor Author
image

Here's what this looks like in a local test. Note: we can't run this in the vercel preview because it needs some data changes to dev.

fastfadingviolets and others added 6 commits October 21, 2026 14:29
Conference testimony is filed against the conference's canonical bill, so it
shares that bill's id with the bill's own testimony. The site now keeps the two
apart the way the server does.

The client gets its own ConferencePosition, ConferenceTestimony and conference
draft types, so the bill and ballot-question displays keep their three
positions and none of them change. Listings for a bill, a profile and recent
testimony skip documents with a conferenceId. Draft and publication lookups
match the conference scope with the functions' matchesTestimonyScope, so a conference draft cannot collide with the
bill's draft. useEditConferenceTestimony drafts and publishes on a conference,
and useConferenceTestimony lists what has been filed on one.

Verified: site tsc is clean. The scope match is the functions' matchesTestimonyScope,
whose unit test passes. On the
emulator a conference testimony was drafted, posted and republished through the
real publish function, and its draft carried the conferenceId.

Not verified: a bill page that has testimony, in the browser, since S2581 has no
bill document locally. The listing hooks have no unit test of their own: their
jest suites already fail at the base commit (see the crumb on #69).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ient's mockup

The conference page gets the client's Public Input feed and Add Your Input
panel, built from k-g-k/maple-conference at 19bd101 (the "/" routes, which use
its BUILD flag preset).

The title carries a "Share your input" button, and Public Input joins the
section tabs between Committee and Bill Text. Public Input lists what has been filed as cards,
narrowed by account type and position, with a Following filter that appears
once the reader follows someone, five to a page, and a modal that opens an
entry whole. Each card has the kebab with Follow and Report. The panel writes
on the existing draft and publish hooks: autosaved drafts, a review step, and
posting, which puts the panel away and scrolls to a "Your Input" section.
Editing reopens the panel on the posted text; because the server requires a
reason for every republish, the review step shows one Reason for Edit field
only then, the one control the mockup does not show. The page is keyed by
conference so moving to another starts clean.

Left out on purpose: the mockup's weekly-email checkbox (nothing on the server
backs it) and its Share action (it has no destination). The followers' digest
email and newsfeed card are unchanged and still assume three positions.

Closes #69

Verified: site tsc and eslint are clean. jest passes for components/conference
(18, including a test that moves between conferences and failed before the
page was keyed). The feed card, filter row, and the compose panel and footer
were measured against the running mockup and match, bar the differences below.
Posting, editing, republishing, following (including from the card menu,
which then offers Unfollow), the entry modal and filters were exercised in the browser on the emulator. The server commits below it pass the
testimony, ballot question, moderation, conferences and backfill integration
suites (59).

Not verified: phone width. The review step and the Your Input section were
compared by eye only. The feed's headings and filter row do not pin as the
mockup's do. The mockup labels the reader's new entry "Just now" and animates
the "on the record" line; ours shows the date. The account-type tooltip is a
native title. Avatars are initials. The panel sits under our 72px navbar where
the mockup's is 60px. The report modal and the unverified-email path were
not exercised. The translation files the page loads
have no test.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Publishing, and republishing, testimony on a conference that is reportFiled,
enacted or dissolved now fails with "not accepting testimony". Deleting stays
allowed so people can still take their testimony down. Unlike the ballot
question phase, which only the client enforces, this is checked in the publish
transaction too, so calling the function directly cannot bypass it.

On the site, a closed conference keeps its Public Input but offers no way to
write: the title's Share your input button and the Edit item in the author's
own card menu only show while it is appointed. The testimony detail page sent
conference testimony to the bill's form; it now links to the conference page
while the conference is appointed and offers no add or edit after.

Closes #78

Verified: the integration tests added here (publish refused for each closed
status, republish refused after the status flips, delete allowed on a closed
conference) pass. jest covers the closed page (no Share button, Public Input
still there), the card menu without Edit on a closed conference, and the
detail page's conference link and its absence once reported; the menu test
fails with the gate taken out. Site tsc is clean.

Not verified: a reportFiled conference in the browser on the emulator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nce data

yarn emulators:start loads tests/integration/exportedTestData, which predates
conferences: no conferences, no court-194 members or bills, and test@example.com
unverified. Restarting the emulators, or running the integration suite, wipes
whatever was added by hand.

seedLocal reads conferences/194/*.yaml, queues scraper batches so the functions
emulator fetches the real bills and members they name, waits for them, runs
syncConferences, verifies test@example.com, and writes seven sample testimonies
on one conference. Local only.

Verified: on an emulator started clean from the export it filled in 32 bills,
65 members and 16 conferences in about 106 seconds, and every conference page
then showed its six conferees.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ewsfeed

Conference testimony reaches the author's followers, and both places
they see it treated it as bill testimony: the digest email drew a
broken position image and linked the bill, and the newsfeed card
linked the bill and the bill's testimony page.

The digest gives a conference item the conference id and its stance
in words, taken from the site's wording (a test holds the two equal).
Each of the six slots in the user partial sends it through a new
conferenceItem partial that links the conference. The newsfeed card
says the author gave input to the conference committee, shows the
stance chip from the conference page, and links the conference's
Public Input. Bill and ballot-question items are drawn as before.

Verified: functions jest src (123), including buildDigestData and a
render of the user partial for conference, bill and sixth-slot items;
site tsc; NewsfeedCard jest for a conference and a bill card. On the
emulator: the test user followed an author from the conference page,
a new conference and a new bill testimony by that author both reached
the follower's feed, /newsfeed showed both cards with the right links,
and the digest built from the emulator rendered the conference item
with its stance and conference link beside the bill item's image.
Bill rows of the user partial render the same as on main apart from
whitespace between tags.
Not verified: violet has not looked at the email or the newsfeed in a
browser herself, nor has anyone seen the conference stance cell, now
100px wide and wrapping, in an email client. NewsfeedCard's ballot-question test still fails, as
it does on main (the test env has ballotQuestions off).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Testimony from before conference testimony has no conferenceId, and
Firestore cannot select on a missing field, so every read that kept bill
testimony apart from conference testimony fetched the whole bill and
filtered in memory. backfillTestimonyScope stamps null on whichever of
ballotQuestionId and conferenceId a published, archived or draft
testimony lacks (the ballot-question backfill skipped drafts, and this
replaces it, in CI too), with --dry-run. Drafts are now written with
conferenceId: null.

Publish, delete, the counts backfill, the website's draft and
publication lookup, the bill's listing and a testimony's version history
then filter both fields in the query, with their limits back: publish,
delete and the counts backfill are main's code plus one filter, and
matchesTestimonyScope goes. The version history fixes a conference
filing listing its author's bill versions on the canonical bill, and the
reverse. Unscoped listings keep the in-memory filter. Two composite
indexes and a conferenceId override are added; the bill listings rely on
index merging, as the ballotQuestionId filter already did. The
integration test data is migrated with the script.

Run per environment, dev then prod: indexes first, then the backfill,
then the functions and hosting, then the backfill again for what the old
code wrote in between. Stamping after the new code is live hides legacy
testimony from its bill and lets a republish duplicate it.

Closes #82

Verified: site tsc; functions jest 120/120; yarn test:integration-ci
tests/integration components/db, 102 pass, including the backfill on
legacy docs and its dry run, republishing backfilled legacy testimony in
place beside a conference filing, and delete skipping a newer conference
filing for the bill's latest (fails without the filter); the script's
dry run, run and second dry run against the exported test data (25
published, 133 archived, 38 drafts, then none left).

Not verified: that the indexes serve every query, which the emulator
does not check, so only dev shows it; the migration on dev or prod data;
the full yarn test:integration-ci, which aborts partway at this commit's
parent too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@Mephistic Mephistic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Love this - just a few small questions, nothing that I consider a blocker to getting this PR merged.

Comment thread scripts/firebase-admin/seedLocal.ts Outdated

await syncConferences(ctx)

const user = await auth.getUserByEmail("test@example.com")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

OOC, if we're relying on fetching an existing test user, why not just set that user to have emailVerified: true in the auth export (or add a new email-verified@example.com test user if we're worried about conflicts with other tests)?


const want = [
...[...bills].map(id => `generalCourts/${COURT}/bills/${id}`),
...[...members].map(id => `generalCourts/${COURT}/members/${id}`)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Should this also generate the MemberSearchIndex now that it's powering parts of the Conference feature? IIRC the job that generates the index is a scheduled function (that will never run in the emulators unless explicitly triggered).

})
})

it("moves only the total for a position without a field in the set", () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Just double-checking to make sure I'm caught up on the desired UX: if a bill receives 100 "Public Inputs" and no actual testimony, we expect it to display on its Bill Details page as having 100 Testimonies (with 0 endorse/neutral/oppose counts and no testimony on the page)?

@fastfadingviolets fastfadingviolets Oct 26, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Great catch. That's not the intended UX at all. It's not what happens today, simply because there's a guard in updateBill that prevents an updateTestimonyCounts from being run with conference testimony on a bill. That said, that check is obviously at the wrong altitude--it needs to live in updateTestimonyCounts. Will fix this.

items.push(
<PolicyActionItem
key="add-testimony"
billName={`${isUser ? "Edit" : "Add"} Testimony for ${conferenceName(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Just double-checking: for a conference, should this be called "Public Input" instead of "Testimony"? Not sure what nomenclature we settled on here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Hmm! Yes, the mockups show "Public Input"--I will make the switch & just give the whole thing a consistency pass

attachmentId: Maybe(String),
editReason: Maybe(String),
ballotQuestionId: Maybe(String)
ballotQuestionId: Maybe(String),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Just to clarify: are ballotQuestionId and conferenceId mutually exclusive? If so, should we enforce that constraint in the type definition?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, great call, let's do this.

/** The mockup's theme.css values, for the colours the site has no token for.
* The page's own surfaces and brand come from the --maple tokens, as in
* ../styles.ts. */
export const ink = {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@k-g-k 100% not a blocker, but do you know if the goal here to have these tokens be part of the re-branded color palate (or are they unique to the Conference feature)? Just want to make sure we're keeping an eye out for UI consistency here.

Comment thread components/conference/input/popover.tsx Outdated

/** The floating surface under a control. `$right` hangs it from the right
* edge; widths and padding are set by whoever extends it. */
export const Menu = styled.div<{ $right?: boolean }>`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

OOC, what does this custom popover give us that the Popover from react-bootstrap doesn't?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Nothing; I just am not a UI person and did not realize we already had a Popover, so I didn't catch that Claude had added an unnecessary one. I'll replace it with the react-bootstrap component. Will also take a peek at any other components and make sure they're actually needed.

fastfadingviolets and others added 2 commits October 26, 2026 13:54
A fresh emulator plus seedLocal left two gaps. test@example.com was
unverified in the shared auth export, so the seed verified it itself;
the export now holds it verified and the seed's step goes. The member
search index behind the legislator pickers is built only by a scheduled
job, which never fires in the emulator, so the pickers were empty; the
seed now runs that job's own handler once the scraped members are in.
The index holds the members the seed scrapes, the conferees, not the
whole House and Senate.

The handler runs in the script's process because the emulator's route to
scheduled jobs, triggerPubsubFunction, fails on the docker stack, and
runScrapers then waits on it forever.

Closes #108

Verified: on a fresh docker stack, seedLocal runs clean and memberSearch
holds 39 representatives and 26 senators; in Chrome, test@example.com
signs in already verified, the edit-profile Representative picker lists
names, and the user posts conference input with no profile redirect.
Integration suites with the change: testimony, moderation, profile and
auth pass on the host; ballotQuestions, backfillTestimonyScope, profile
and auth pass in a container, where testimony and moderation fail
because its functions emulator does not serve callables.

Not verified: CI's integration list in one run on the host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What a testimony counts toward was decided in five places: publish and
delete each asked again for every target, and the count helper took
whatever fields its caller handed it, so a conference position given
bill fields bumped the bill's total and none of its stances.

Publish and delete now resolve the target once (targetOf: the bill, a
ballot question on it, or a conference on it) and make one counts update
against it, with the count fields chosen by the target's kind. The
helper throws on a position outside those fields: draft validation keeps
them out, so one there is a broken invariant. Drafts on both sides, and
the site's Testimony, refuse to name both a ballot question and a
conference. Delete now skips the counts when the target document is
gone, a bill included, as it already did for ballot questions and
conferences.

Closes #109

Verified: functions tsc and functions testimony jest (14), both re-run
after the final cleanup; integration testimony, ballotQuestions and
moderation (51/51) on a throwaway emulator, run before that cleanup
(delete's target became a local, the helper's field check became a key
check), including a refused two-target draft and a delete that fails on
a mismatched position without touching the counts; on the docker stack,
posting Pass the Senate version on a conference moved its total and
stance from 0 to 1 and left its bill's counts at 0.

Not verified: bill testimony and admin delete through the browser (the
integration suite covers both callables); the integration suite after
the final cleanup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fastfadingviolets and others added 2 commits October 26, 2026 15:16
The mockup calls what people file on a conference input, and most of
the conference surfaces already did. Two places still said testimony:
the testimony page's Actions item ("Add/Edit Testimony for ...") and the
report and delete-request modals opened from an entry on the conference
page ("Report Testimony", "submitted on the wrong bill").

The Actions item now reads Add or Edit Your Input for the conference.
The report modal takes an optional title, and the delete-request modal
an optional title, wrong-target reason and rescind text, so the
conference page words both for input while bill and ballot-question
pages keep theirs. The digest's New Testimonies count and the rest of
the testimony page stay as they are.

Closes #110

Verified: PolicyActions jest; conference, TestimonyCard and testimony
jest (56/57, the one failure is ViewTestimony's All Testimonies test,
which fails without this change); site tsc; in Chrome on the local
emulator, the report modal on another person's entry reads Report Input,
and the delete request on your own reads Report Input with the
conference reason and rescind text (checked before the delete request
was folded back into the shared modal).

Not verified: the Actions item in the browser, since the testimony page
crashes on conference input (filed separately); the delete request in
the browser after it moved back into the shared modal; a bill page's
report modal still reading Report Testimony.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The entry kebab, the account-type and position pickers and More Filters
ran on a home-made popover that declared menu and listbox roles but
neither moved focus nor answered arrow keys. They now run on Dropdown,
as the conference picker does: the arrows move through the items,
Escape closes and hands focus back to the toggle, and Tab out closes.
Every menu is role=menu (the pickers' choices are menuitemradio), since
Dropdown is a menu button rather than a listbox. Toggles say they open a
menu before the first open, when Dropdown does not yet. Popper does not
re-place a menu on scroll, since a menu sits in its control's own flow.

Laid beside the mockup, the menus also match where the popover did not:
items are weight 500, account-type rows keep the taller line, the
position pill's chevron is mid ink, the account menu's first row reads
"All Accounts", and on a phone a picker's menu stays under its control
and narrows to the room on its right instead of sliding left. The
account and position menus still sit about 1.5px nearer their control
than the mockup's, which hangs them from a wrapper a little taller than
the control itself.

Closes #111

Verified: jest components/conference (a new kebab keyboard test included);
each menu open and closed beside the mockup at 19bd101, measured at
desktop width and in a 390px frame (width, offset, padding, border,
radius, shadow, row heights, selected colour); by keyboard in Chrome:
open, arrows, Enter, Escape, Tab out, Space in More Filters keeping it
open, and the focus ring on its rows.

Not verified: with a screen reader; on a real phone or with the
viewport resized while a menu is open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 27, 2026

Copy link
Copy Markdown

Deployment failed for project maple-dev with the following error:

The provided GitHub repository does not contain the requested branch or commit reference. Please ensure the repository is not empty.

@Mephistic Mephistic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM - let's try this out on DEV!

@Mephistic
Mephistic merged commit dac062b into codeforboston:main Oct 27, 2026
8 checks passed

This branch was successfully deployed

1 active deployment
Preview – maple-dev — d97c28be Deployed Oct 27, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants