Skip to content

Add How to Vote screen and remove inferred election deadlines - #292

Closed
ThatXliner wants to merge 2 commits into
mainfrom
feat/how-to-vote
Closed

Add How to Vote screen and remove inferred election deadlines#292
ThatXliner wants to merge 2 commits into
mainfrom
feat/how-to-vote

Conversation

@ThatXliner

@ThatXliner ThatXliner commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Closes #272.

The Elections tab already explains what is on the ballot. It never explained how to turn a decision into a counted vote. This adds that half — and removes the invented deadlines currently standing in for it.

Design spec and mockups: https://claude.ai/code/artifact/010f959a-576a-4884-8c34-3648699589fb

Screenshots

Captured on an iPhone 17 Pro simulator (iOS 26.5) running this branch.

Note on the data: the GOOGLE_CIVIC_API_KEY in .env currently returns 403 — Civic Information API has not been used in project 963502945151 before or it is disabled, so live lookups fail locally. These shots run against a locally-seeded civic_api_cache row shaped like a real Sacramento County voterinfo response. The seed is local-only and was removed afterwards; the deadline states below are genuine — they are what the screen renders whenever no deadline has been sourced, which today is always.

1. Entry point on the Elections tab

Docked under the election hero, above the ballot tabs. Note the hero now shows only Election Day — the invented "Registration closes" and "Ballots mailed" rows are gone.

01-elections-entry

2. How to Vote — overview

Election, truncated address, a registration line that asserts nothing, and the four methods with icon+word availability chips.

02-how-to-vote-overview

3. A method expanded, with locations

Three steps, then the drop boxes with hours and Get Directions. Locations cap at three with "Show all {n}", matching PollingPlacesSection.

03-dropbox-expanded

4. Vote by mail — the honest gap

The five-step checklist, then the unavailable state that stands in for every deadline Billion cannot source: "We won't guess at a date or a location." This is the state that replaces the fabricated electionDay - 15 dates.

05-mail-expanded-honest-gap

5. What to bring, and the official source footer

ID rules are deferred to the authority rather than restated. The named election body and its links are the last card in every state.

04-source-footer

6. No address set

Both entry points still render — the section never silently disappears — and the destination stays useful with a national fallback.

06-elections-no-address 07-how-to-vote-no-address

The trust problem this also fixes

Two shipped components synthesized legal deadlines from arithmetic and rendered them with the same authority as real data:

Component Row Computed as
ElectionHero "Registration closes" electionDay − 15
ElectionHero "Ballots mailed" electionDay − 8
KeyDatesSection "Registration Deadline" electionDay − 15
KeyDatesSection "Early Voting Starts" electionDay − 29

None were sourced or labelled as estimates, and all were painted in the same amber/green semantics used for the Election Day we genuinely get from Google Civic. Those offsets approximate one California cycle and are wrong for most jurisdictions and years. They're removed, along with the shiftDays helper whose only callers they were.

The Elections tab footer row also read "Find your polling place / Verified on vote.gov" while pushing to an internal screen. Billion performs no such verification — the row now describes what it does and opens How to Vote.

What ships

Entry point — a card docked directly under ElectionHero, above the ballot tabs. The hero has just said which election and when, so "how do I vote in it" is the next sentence, and anyone who came only for logistics never scrolls a contest. Renders in every state, including with no address.

The screen — election and Election Day, truncated registered address with Edit, the four ways to vote as a single-expand accordion, and a named election authority with working links as the last card in every state.

Honest gaps. Method availability distinguishes unknown ("the county hasn't published this yet") from unavailable ("an official source says it isn't offered"). Collapsing the two would tell a voter a method doesn't exist when we simply don't know. No deadline is displayed unless an official source supplied it; today that means the deadline slots render their "not available" variant and route to the county. Nothing on this screen asserts registration status.

States handled — no address, loading, lookup error, out-of-coverage, mail-only, locations unpublished, Election Day, and post-election.

Notes for review

  • apps/expo/src/utils/voting.ts holds all the derivation logic and is pure — 20 tests cover it, including a guard that no generated step or subtitle can name a month.
  • Adds a test script to @acme/expo (tsx --test), matching the convention in @acme/api. The lockfile change is a 3-line importer entry; --frozen-lockfile passes.
  • packages/api now exports AdministrationRegion, AdministrationBody, and ElectionOfficial — already-defined Civic types that weren't re-exported.
  • Three icons added to the shared set: alert, mail, inbox.

pnpm format, lint, typecheck, and test all pass across the monorepo (211 tests).

Follow-ups, not in scope here

  • /local-elections is titled "Where & How to Vote" and its polling-place and key-dates sections now overlap this screen. The clean split is How to Vote owning logistics and that screen keeping reps, local bills, and meetings — but it's a product call, not a refactor to slip into this PR.
  • Pre-existing off-by-one in date display. formatDate/monthDay in ~/utils/dates do new Date("2026-11-03"), which parses as UTC midnight and renders as Nov 2 in any timezone west of UTC — visible in the screenshots above. It predates this PR and affects every screen using those helpers, so I left it alone rather than change shared behaviour here, but it matters more on a deadline screen than elsewhere. Worth a follow-up.
  • Sourced deadlines, ID requirements, and "last verified" timestamps need a backend surface. Every field's unavailable variant is already designed and built, so they can land one at a time without blocking on the full set.

🤖 Generated with Claude Code

The Elections tab explains what is on the ballot but never explained how to
cast one. How to Vote is the logistics half: which election, where you're
registered, the ways you can vote, where to go, and who to ask.

The screen renders only what an official source returned. Billion has no
sourced deadline feed, so every deadline slot shows an explicit "not
available" state and routes to the county rather than showing a date.

That posture required deleting some existing behaviour. ElectionHero rendered
"Registration closes" at electionDay-15 and "Ballots mailed" at electionDay-8;
KeyDatesSection rendered "Registration Deadline" at -15 and "Early Voting
Starts" at -29. None were sourced or hedged, and all were styled like the
Election Day we actually get from Google Civic. Those offsets approximate one
California cycle and are wrong elsewhere, so they're gone along with the
shiftDays helper that existed to compute them.

The Elections tab footer row also claimed "Verified on vote.gov" while pushing
to an internal screen. Billion performs no such verification; the row now says
what it does and opens How to Vote.

Method availability distinguishes "the county hasn't published this yet" from
"this isn't offered" — collapsing the two would tell voters a method doesn't
exist when we simply don't know. Locations, loading, error, out-of-coverage,
mail-only, Election Day, and post-election states are all handled.

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

vercel Bot commented Aug 16, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
billion-nextjs Ready Ready Preview Aug 16, 2026 5:18am

…l government

Three review fixes.

Steps are now a summary of an authority's published instructions rather than
Billion's own advice, so they only render when we can name and link the page
they summarize. `instructionsUrl` gates `steps` entirely: no source, no
summary — the card becomes pure routing, without narrating why. The expanded
card names the authority above the steps and promotes the official link from a
footnote to a primary action.

The registration row asked nothing and could answer nothing. It said "Billion
can't confirm whether you're registered" and, wherever Civic omitted both
registration URLs, rendered with no action at all — a prompt with no exit. It
now asks the question the reader actually has and always resolves somewhere:
county tool, else state, else vote.gov.

The previous commit repointed the only link to /local-elections and stranded
it. Local bills and upcoming meetings became unreachable, and
`legistar.getMeetings` lost its last consumer. That screen is now "Your Local
Government": the three sections How to Vote superseded are gone, and a card in
Browse — under the jurisdiction row, where the reader is already asking which
government they're looking at — is the way back in.

Legistar serves San Jose, Santa Clara County, and Sunnyvale to everyone
regardless of address, so "Your" is earned by an address match and the scope is
stated plainly otherwise. A Sacramento reader is not told San Jose is theirs.

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

Copy link
Copy Markdown
Collaborator Author

Loaded-state screenshots (commit 1195c11)

Running on an iPhone 17 Pro simulator against a local API, with a seeded
civic_api_cache row for a San José address. Legistar is live upstream
the local bills and meetings below are real records, not fixtures.

Image slots below. GitHub's attachment upload is web-only (no CLI/API
path, and gists reject binary), so the five PNGs are in
~/Desktop/billion-pr292-screenshots/ — drag each onto its slot when
editing this comment.

1 · Elections tab — entry point loaded

How to Vote · 2 ways to vote in this election, counted from published
locations. ElectionHero shows only Election Day; the inferred
electionDay - 15 / - 8 rows are gone.

2 · How to Vote — registration fix

Not sure if you're registered? with Check my registration status always
present. Previously this read "Billion can't confirm whether you're
registered" and rendered no action at all wherever Civic omitted both
registration URLs.

3 · Attributed step summary

Deep-linked via /how-to-vote?method=mail. The kicker names the authority —
SUMMARIZED FROM SANTA CLARA COUNTY REGISTRAR OF VOTERS — before the reader
reaches the steps, and instructionsUrl gates them: no citable source, no
summary.

Note Vote by mail · Return deadline not available · Not published sitting
directly above sourced steps. That is the intended split — the county
publishes instructions, so the steps render; it publishes no return deadline
through Civic, so Billion states that plainly instead of computing one.

4 · Your Local Government — un-orphaned

The screen the previous commit stranded. Real Legistar matters and meetings.
Reached from a new card in Browse, which reads "Your Local Government ·
San José" only because the saved address matched a covered jurisdiction.


Two things these screenshots expose

Jurisdiction labels are wrong on local bills. Items titled
"Councilmember Jain", "City Council Request" and "Third Zoning Code Cleanup
Ordinance" are tagged SANTA CLARA COUNTY. The meetings list gets it right
(CITY OF SAN JOSE vs SANTA CLARA COUNTY), so this is specific to
getLocalBills. Pre-existing, and exactly what #282 means by "city and county
decisions must be clearly separated."

"Local Bills" is the wrong noun. #282 is explicit that the term "imports a
federal/state legislative concept that does not match how most local
governments record their work"
and that the unit should be a local decision /
agenda item. The screenshots make that obvious — a closed-session labor
negotiation is not a bill. Renaming belongs with #282, not here.

Also still true from #282: those cards have no press behaviour. Un-orphaning
the screen restores content you can't tap into — better than unreachable, not
good.

@ThatXliner

Copy link
Copy Markdown
Collaborator Author

Closing unmerged — this was an experiment against the How to Vote design, and
it did its job by proving the feature isn't buildable as specified yet.

Why it isn't merging. Everything voting-related in the screenshots above is
fabricated. The civic_api_cache rows behind them were hand-written for UI
testing: real authority names and real building addresses, but invented polling
locations, hours, windows, and mailOnly flags — and an invented election.
Only the local-government content (Local Bills, Upcoming Meetings) was real,
since Legistar is a live upstream API. Those rows have been cleared from the
local database.

That combination — fabricated values attributed to a real authority — is
exactly the shape of a past incident, and it isn't a base to merge from.

What replaces it. #272 is now an epic covering the design plus the data
sources it depends on: #294#298 for the facts, #299 for verification metadata,
#300 for the Google Civic concentration risk.

One piece should still land. #301 — removing the inferred
electionDay - 15 / - 8 / - 29 deadlines from ElectionHero and
KeyDatesSection — needs no new data and no design decision. It is deletion,
it reduces risk immediately, and it can be cherry-picked from 1195c11 and
e5b1dc4 on this branch. Please don't let it get lost with the rest of this PR.

Also worth keeping as reference:

  • apps/expo/src/utils/voting.ts — the derivation model, including the
    unknown vs unavailable distinction and the source-gating of step summaries
  • The honest-degradation states — "Not published", "Return deadline not
    available", "Verification date unavailable"
  • apps/expo/src/utils/local-government.ts — address→covered-jurisdiction
    matching, a starting point for the district-relevance requirement in Build a local-government decision experience, starting with San José #282

The branch stays pushed. Nothing here is lost, it just shouldn't ship yet.

@ThatXliner ThatXliner closed this Aug 16, 2026
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.

Epic: How to Vote — voting logistics and the data sources it needs

1 participant