Skip to content

VxAdmin: Add support for qualified write-in candidates#8254

Open
nikhilb4a wants to merge 10 commits intomainfrom
nikhil/7898-qualified-write-in-candidates
Open

VxAdmin: Add support for qualified write-in candidates#8254
nikhilb4a wants to merge 10 commits intomainfrom
nikhil/7898-qualified-write-in-candidates

Conversation

@nikhilb4a
Copy link
Copy Markdown
Contributor

@nikhilb4a nikhilb4a commented Apr 6, 2026

Overview

#7898

This PR adds a feature flag that determines whether write-in candidates must be qualified, meaning they are pre-input in VxAdmin before adjudication, and any write-in that doesn't match a qualified candidate will be adjudicated as invalid.

It adds an interface in VxAdmin to manage qualified write-in candidates. If a candidate is deleted after having an adjudicated vote, the write-in will be reset to pending, and the cvr-contest will have adjudicated votes deleted, with the cvr being marked as pending adjudication again.

It updates the contest adjudication UI to restrict adding new candidates via write in adjudication.

A follow up to this will be adding a UX improvement where write-ins for contests that have no qualified candidates are auto-adjudication as invalid.

Demo Video or Screenshot

Screen.Recording.2026-04-13.at.4.33.13.PM.mov

No contests with write-ins:
Screenshot 2026-04-08 at 2 41 49 PM

No qualified candidates for write-in in management interface (also contrived example when contests overflow):
Screenshot 2026-04-08 at 3 37 02 PM

No candidates match input during write-in adjudication:
Screenshot 2026-04-09 at 4 10 32 PM

Updated tally report showing all qualified candidates:
Screenshot 2026-04-10 at 10 12 25 AM

Testing Plan

Checklist

  • I have prefixed my PR title with "VxDesign: ", "VxPollBook: ", or "HWTA: " if my change is specific to one of those products.
  • I have added logging where appropriate for any new user actions.
  • I have added the "user-facing-change" label to this PR, if relevant, to automate an announcement in #machine-product-updates.

@nikhilb4a nikhilb4a force-pushed the nikhil/7898-qualified-write-in-candidates branch 12 times, most recently from a8441dc to a81643b Compare April 10, 2026 20:32
@nikhilb4a nikhilb4a requested a review from Copilot April 10, 2026 20:34
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a “qualified write-in candidates” mode to VxAdmin, introducing a managed list of allowed write-in candidates and ensuring they appear in write-in summaries/reports (including zero tallies) while restricting ad-hoc candidate creation during adjudication.

Changes:

  • Add a system setting (areWriteInCandidatesQualified) and expose it in VxDesign system settings UI.
  • Add VxAdmin UI + API endpoints for managing qualified write-in candidates, and restrict write-in adjudication UI from creating new candidates in qualified mode.
  • Update tabulation/summaries/reports to include qualified candidates with 0 tallies when qualified mode is enabled.

Reviewed changes

Copilot reviewed 28 out of 28 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
libs/ui/src/reports/write_in_adjudication_report.tsx Plumbs showZeroTallyCandidates into the UI report.
libs/ui/src/reports/contest_write_in_summary_table.tsx Conditionally includes 0-tally candidates in the summary table.
libs/ui/src/reports/contest_write_in_summary_table.test.tsx Adds coverage for showing/hiding 0-tally candidates.
libs/types/src/system_settings.ts Adds areWriteInCandidatesQualified to system settings schema.
apps/design/frontend/src/system_settings_screen.tsx Adds checkbox to configure qualified write-in mode.
apps/design/frontend/src/system_settings_screen.test.tsx Updates control count for the added checkbox.
apps/admin/frontend/src/screens/write_in_candidates_tab.tsx New tab UI for managing qualified write-in candidates per contest.
apps/admin/frontend/src/screens/write_in_candidates_tab.test.tsx Tests contest list behavior, edit flows, deletion modal, and validation.
apps/admin/frontend/src/screens/contest_adjudication_screen.tsx Passes qualified-mode flag into write-in adjudication UI.
apps/admin/frontend/src/screens/contest_adjudication_screen.test.tsx Adds test ensuring qualified mode prevents “press enter to add”.
apps/admin/frontend/src/screens/ballot_adjudication_screen.tsx Sources qualified-mode flag from system settings and passes it down.
apps/admin/frontend/src/screens/adjudication_start_screen.tsx Adds qualified-mode tab bar (Ballot Adjudication / Write-In Candidates).
apps/admin/frontend/src/screens/adjudication_start_screen.test.tsx Updates setup to expect system settings fetch.
apps/admin/frontend/src/router_paths.ts Adds route for /adjudication/candidates.
apps/admin/frontend/src/components/write_in_adjudication_button.tsx Disables ad-hoc candidate creation in qualified mode + adjusts messaging.
apps/admin/frontend/src/components/navigation_screen.tsx Adds style passthrough to MainContent for layout customization.
apps/admin/frontend/src/components/entity_list.tsx Adds selected support (aria-selected + styling) and makes onHover optional.
apps/admin/frontend/src/components/app_routes.tsx Adjusts routing so /adjudication/* goes through the start screen.
apps/admin/frontend/src/api.ts Adds react-query hooks for get/update qualified write-in candidates.
apps/admin/backend/src/types.ts Introduces QualifiedWriteInCandidateRecord type for management UI.
apps/admin/backend/src/tabulation/write_ins.ts Ensures qualified candidates appear in results with 0 tallies.
apps/admin/backend/src/tabulation/write_ins.test.ts Adds tests for qualified mode behavior pre/post adjudication.
apps/admin/backend/src/tabulation/full_results.ts Loads qualified candidates (when enabled) during full tabulation.
apps/admin/backend/src/store.ts Adds store queries + batch update/delete logic for qualified candidates.
apps/admin/backend/src/reports/write_in_adjudication_report.ts Enables 0-tally candidate display in WIA report when qualified mode is on.
apps/admin/backend/src/app.ts Adds API endpoints for qualified candidate management + logging.
apps/admin/backend/src/app.adjudication.test.ts Adds integration coverage for qualified candidate management and deletion effects.
apps/admin/backend/src/adjudication.ts Prevents auto-deleting write-in candidates when in qualified mode.

Comment thread apps/admin/backend/src/store.ts
Comment thread apps/admin/backend/src/store.ts Outdated
Comment thread apps/admin/frontend/src/screens/write_in_candidates_tab.tsx
Comment thread apps/admin/backend/src/app.ts
Comment thread apps/admin/frontend/src/screens/write_in_candidates_tab.tsx
@nikhilb4a nikhilb4a force-pushed the nikhil/7898-qualified-write-in-candidates branch 4 times, most recently from b1f71b7 to ccbfaf9 Compare April 10, 2026 22:53
@nikhilb4a nikhilb4a force-pushed the nikhil/7898-qualified-write-in-candidates branch 3 times, most recently from f257f53 to 13c7dd3 Compare April 13, 2026 22:12
@nikhilb4a nikhilb4a force-pushed the nikhil/7898-qualified-write-in-candidates branch from 13c7dd3 to 493340e Compare April 13, 2026 22:37
@nikhilb4a nikhilb4a requested a review from jonahkagan April 13, 2026 23:37
@nikhilb4a nikhilb4a marked this pull request as ready for review April 13, 2026 23:37
Comment thread apps/admin/backend/src/store.ts Outdated
Comment thread apps/admin/backend/src/store.ts Outdated
);

// For each affected CVR, clear adjudicated votes for the contest and
// mark the CVR as not adjudicated
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do we really want to clear all of the other adjudicated votes for the contest? Or do we just want them to re-adjudicate the write-in? If I had done a bunch of other adjudication work for a contest, I don't think I'd want to have to redo it. I guess we don't really have a concept of partially adjudicating a contest currently though - is that why we're clearing all of it?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Also, what happens if there are multiple write-ins adjudicated for the contest? Do we also need to clear their adjudications?

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.

It's a good question. The reason I cleared it is that we currently set a contestCvrTag as resolved depending on if adjudicatedVotes for the contest is undefined. So that is how I'm signaling this contest is not resolved. This could be changed if the flow checked write-in records directly to determine if a contest still requires adjudication.

The tradeoff I made was that I left write-in records adjudicated for the contest, so when a user does open up a contest to re-adjudicate the write-in, they have maintained the write-ins they've previously adjudicated for that contest.

Maybe that just makes things more confusing?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It does seem a little weird to clear some of their work but not all of it, especially since the work that gets cleared is unrelated to the write-in they deleted.

This could be changed if the flow checked write-in records directly to determine if a contest still requires adjudication.

This sounds like an interesting direction to explore, but I can't immediately imagine what the consequences of that would be.

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.

Okay I think for now then I will clear other write-ins within the cvr-contest as well so the contest gets fully reset. Especially given this is a pretty rare case.

And then when I do the frontend refactor to make adjudication saves on the ballot level vs. the contest level, CvrContestTags might be dropped and then I can revisit changing how we determine if a contest still requires adjudication.

</Section>
<NetworkSection />
</Column>
)}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Seems a little weird that we need to load system settings again within this component when we've already branched based on the qualified write-in mode flag in the parent component.

Can this logic be pulled up into the parent?

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.

Two questions -

  1. Which parent are you referencing? Is it that AdjudicationStartScreen fetches system settings and then BallotAdjudicationScreen also fetches them?
  2. This brings up a point I've wondered about regarding query caching and could benefit from hearing your thinking. Since we cache queries, why is it better to pass query results from parents to children vs. just re-querying within the child?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

  1. I was referring to BallotAdjudicationContent and its parent AdjudicationStartScreen. I saw that in a later commit you changed it to pass the flag as a prop instead, but I would still offer similar feedback with that pattern. The main thing I'm trying to point out is that it's slightly leaky to have the parent component distinguish between the two different cases and then also have to distinguish the two cases within a shared child.

  2. I don't have a strong stance on the best way to do this in all cases. My general approach is that I try to match queries to testing boundaries so that I can write tests that mock API request/response patterns. If I pass in props to a component I'm testing, it often makes it harder to test how those props change in response to user actions that make API requests. Often this means queries at the level of screen components, with data passed via props internally.

The cache is just for performance, not correctness. If a component loads data from an API query, then it shouldn't assume that the cache has the data already. It might be tempting if that component is only used in one place and we know its parent already loaded that data, but it sets up a bit of a footgun if that component ever gets used elsewhere.

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.

  1. I see, that makes sense. It did feel a little funny. The prop is passed because the child only renders the header if the tab bar is not there. That being said, I'll be dropping the tab bar in the follow up PR, so I'll probably just leave as is for now.

  2. That's a helpful explanation, thanks.

Well, I guess I hadn't been assuming the data would already be there, but was thinking there was not a risk to having the component use the query, if it requires the data. Because if the parent already queried for it, great, it's in the cache. If the parent didn't, that's fine, because the component needs the data, so the query is required, whether it's passed from a parent or not. Not saying that's the right way to think, but that is the thinking that has made me feel unsure why it would be a footgun potentially (sorry if I misunderstood your point)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yes, good point. I guess I was thinking about a case where the child component doesn't handle the query loading state since it relies on the parent having already loaded the data - that would be the footgun. Not an issue here though. I agree it's fine if both components handle the loading state.

<Container>
<ContestListContainer>
<EntityList.Box>
<EntityList.Items>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Might be nice to have a header with a title for this list - e.g. "Contests"

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.

Screenshot 2026-04-14 at 2 27 51 PM

I initially left it out because I thought it might be implicitly clear, but I can see the reasoning for including it. It looks a little funny in this quick mockup where I just added the header, but maybe there is a way to improve it.

I'm thinking I'll just move forward in this PR without it and then bring it up along with this comment at Software Design tomorrow to fix in a follow up.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sounds good

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.

Discussed during Software Design Team that the tab bar should be dropped, will be done in a follow up PR that redesigns the adjudication start screen

Comment thread apps/admin/frontend/src/screens/write_in_candidates_tab.tsx Outdated
Comment thread apps/admin/frontend/src/screens/write_in_candidates_tab.tsx
Comment thread apps/admin/frontend/src/screens/write_in_candidates_tab.tsx Outdated
Comment thread apps/admin/frontend/src/screens/write_in_candidates_tab.tsx
};
}
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why do we need to implement this logic twice (once in getElectionWriteInSummary and once in modifyElectionResultsWithWriteInSummary? Is there a way we could implement it earlier in the pipeline so as to only do it once?

@nikhilb4a nikhilb4a force-pushed the nikhil/7898-qualified-write-in-candidates branch 3 times, most recently from 6411bd9 to ffb97e1 Compare April 15, 2026 20:00
…nd tab UX

Backend: use ON DELETE SET NULL on the write_ins FK so removing a qualified
candidate auto-resets its adjudicated rows to pending, dropping the
hand-rolled cleanup and the adjudicated_at column. Frontend:
derive new-vs-saved candidates from the query rather than a local isNew
flag, decouple the start-edit and add-candidate flows, and surface a
loading state on the empty-contest
@nikhilb4a nikhilb4a force-pushed the nikhil/7898-qualified-write-in-candidates branch from ffb97e1 to caf16c4 Compare April 15, 2026 23:18
@nikhilb4a
Copy link
Copy Markdown
Contributor Author

Validation error callouts -
https://github.com/user-attachments/assets/50c2679a-1cf0-4c16-a728-53ee2314c18f

Updated callout when there are no contest qualified candidates (changed to neutral because of contrast issue) -
Screenshot 2026-04-15 at 4 20 03 PM

…s in centralized location

Disable the report's "insignificant write-in" bucketing in qualified mode
(it was dropping 0-vote candidates whenever a winner had non-zero votes),
and consolidate qualified-candidate injection into tabulateWriteInTallies
as the single source of truth.
@nikhilb4a nikhilb4a force-pushed the nikhil/7898-qualified-write-in-candidates branch from caf16c4 to 21d3427 Compare April 15, 2026 23:25
@nikhilb4a nikhilb4a requested a review from jonahkagan April 15, 2026 23:57
Copy link
Copy Markdown
Contributor

@jonahkagan jonahkagan left a comment

Choose a reason for hiding this comment

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

Looks good! Thanks for working through all that feedback. The commit that simplified where the 0 tallies were added was a particularly nice improvement

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants