diff --git a/apps/expo/package.json b/apps/expo/package.json
index d7679073..2725e964 100644
--- a/apps/expo/package.json
+++ b/apps/expo/package.json
@@ -11,6 +11,7 @@
"ios": "expo run:ios",
"format": "prettier --check . --ignore-path ../../.gitignore --ignore-path .prettierignore",
"lint": "eslint --flag unstable_native_nodejs_ts_config",
+ "test": "tsx --test \"src/**/*.test.ts\"",
"typecheck": "tsc --noEmit",
"build:ios": "expo prebuild --platform ios --clean",
"build:android": "expo prebuild --platform android --clean"
@@ -81,6 +82,7 @@
"eslint": "catalog:",
"prettier": "catalog:",
"tailwindcss": "catalog:",
+ "tsx": "^4.21.0",
"typescript": "~6.0.3"
},
"overrides": {
diff --git a/apps/expo/src/app/(tabs)/elections.tsx b/apps/expo/src/app/(tabs)/elections.tsx
index a376501b..90ee9f01 100644
--- a/apps/expo/src/app/(tabs)/elections.tsx
+++ b/apps/expo/src/app/(tabs)/elections.tsx
@@ -14,6 +14,7 @@ import type { Contest } from "@acme/api";
import { AddressAutocomplete } from "~/components/AddressAutocomplete";
import { ElectionHero } from "~/components/ElectionHero";
import { ElectionResultsSection } from "~/components/ElectionResultsSection";
+import { HowToVoteEntryCard } from "~/components/HowToVoteEntryCard";
import { RepsSection } from "~/components/RepsSection";
import { Text } from "~/components/Themed";
import { Card, Icon, Kicker, Segmented, TabScreen } from "~/components/ui";
@@ -21,7 +22,9 @@ import { posthog } from "~/config/posthog";
import { useUserAddress } from "~/hooks/useUserAddress";
import { colors, fontBody, hair, planes } from "~/styles";
import { trpc } from "~/utils/api";
+import { daysUntil } from "~/utils/dates";
import { groupContestsByLevel, measureIsStatewide } from "~/utils/elections";
+import { buildVotingPlan, electionPhase } from "~/utils/voting";
type BallotTab = "candidates" | "measures";
@@ -212,6 +215,13 @@ export default function ElectionsScreen() {
// The address-specific election the ballot belongs to.
const selected = unsupportedState ? undefined : voterInfoQuery.data?.election;
+ // Voting logistics for the entry card. Derived from the same response the
+ // ballot uses, so the card never disagrees with the screen it opens.
+ const votingPlan = buildVotingPlan(
+ unsupportedState ? undefined : voterInfoQuery.data,
+ );
+ const phase = electionPhase(selected?.electionDay);
+
const contests = unsupportedState
? []
: (voterInfoQuery.data?.contests ?? []);
@@ -290,6 +300,27 @@ export default function ElectionsScreen() {
{/* election hero — what election is happening, what it means */}
{selected && }
+ {/* How to Vote — the logistics half of the tab. Sits right under the
+ hero so "how do I vote in it" follows "which election is it", and
+ lands above the ballot list for anyone who only came for logistics. */}
+
+ {
+ posthog.capture("how_to_vote_opened", {
+ entry_point: "elections_hero",
+ days_until_election: selected
+ ? daysUntil(selected.electionDay)
+ : null,
+ available_methods: votingPlan.availableCount,
+ });
+ router.push("/how-to-vote");
+ }}
+ />
+
+
{/* live results (CA SOS feed): statewide + the voter's district races,
scoped from their ballot. Self-hides when off-season. Only
meaningful once we know the voter is in a state we cover. */}
@@ -482,21 +513,34 @@ export default function ElectionsScreen() {
)}
- {/* polling place exit */}
+ {/* polling place exit — routes into How to Vote. The old subtitle
+ claimed "Verified on vote.gov" while pushing to an internal screen;
+ Billion performs no such verification, so the claim is gone. */}
router.push("/local-elections")}
+ onPress={() => {
+ posthog.capture("how_to_vote_opened", {
+ entry_point: "elections_footer",
+ days_until_election: selected
+ ? daysUntil(selected.electionDay)
+ : null,
+ available_methods: votingPlan.availableCount,
+ });
+ router.push("/how-to-vote");
+ }}
>
- Find your polling place
- Verified on vote.gov
+ Where to vote
+
+ Polling places, drop boxes, and hours
+
-
+
diff --git a/apps/expo/src/app/(tabs)/index.tsx b/apps/expo/src/app/(tabs)/index.tsx
index 6b254e16..e6044047 100644
--- a/apps/expo/src/app/(tabs)/index.tsx
+++ b/apps/expo/src/app/(tabs)/index.tsx
@@ -21,6 +21,7 @@ import {
JurisdictionPicker,
JurisdictionScopeRow,
} from "~/components/JurisdictionPicker";
+import { LocalGovernmentCard } from "~/components/LocalGovernmentCard";
import { Text } from "~/components/Themed";
import { ContentCard, Icon, Pill, Pills, SearchInput } from "~/components/ui";
import { posthog } from "~/config/posthog";
@@ -226,6 +227,12 @@ export default function BrowseScreen() {
jurisdiction={jurisdiction}
onPress={() => setJurisdictionPickerOpen(true)}
/>
+ {/* City and county activity — the same "which government?"
+ question as the row above, one level down. */}
+ router.push("/local-elections")}
+ />
();
+ const { address, setAddress, isLoading: addressLoading } = useUserAddress();
+ const [editing, setEditing] = useState(false);
+ const [openMethod, setOpenMethod] = useState(
+ (params.method as VotingMethodId | undefined) ?? null,
+ );
+
+ const hasAddress = !!address;
+
+ const voterInfoQuery = useQuery({
+ ...trpc.civic.getVoterInfo.queryOptions({ address: address ?? "" }),
+ enabled: hasAddress,
+ retry: 1,
+ });
+
+ const data = voterInfoQuery.data;
+ const unsupportedState = !!data && data.normalizedInput.state !== "CA";
+ const election = unsupportedState ? undefined : data?.election;
+ const plan = buildVotingPlan(unsupportedState ? undefined : data);
+ const phase = electionPhase(election?.electionDay);
+
+ const toggleMethod = useCallback(
+ (id: VotingMethodId) => {
+ LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
+ setOpenMethod((prev) => {
+ const next = prev === id ? null : id;
+ if (next) {
+ const method = plan.methods.find((m) => m.id === id);
+ posthog.capture("voting_method_expanded", {
+ method: id,
+ status: method?.status ?? null,
+ location_count: method?.locations.length ?? 0,
+ });
+ }
+ return next;
+ });
+ },
+ [plan.methods],
+ );
+
+ // ---- No address -------------------------------------------------------
+ if (!addressLoading && !hasAddress) {
+ return (
+
+ {editing ? (
+
+ YOUR REGISTERED ADDRESS
+ {
+ void setAddress(addr);
+ setEditing(false);
+ posthog.capture("voter_address_set", { is_update: false });
+ }}
+ />
+
+ ) : (
+
+
+
+
+ Add your registered address
+
+ Voting rules, deadlines, and locations are set county by county.
+ Billion needs the address you're registered at to show the
+ right ones.
+
+ setEditing(true)}
+ style={s.primaryAction}
+ />
+
+ Stored on your device and used only for election lookups.
+
+
+ )}
+
+ IN THE MEANTIME
+
+ These sources work for any address in the United States.
+
+
+
+
+ );
+ }
+
+ // ---- Loading ----------------------------------------------------------
+ if (addressLoading || voterInfoQuery.isLoading) {
+ return (
+
+
+
+
+ Checking how you can vote
+ Looking up this address…
+
+
+
+ );
+ }
+
+ // ---- Lookup error -----------------------------------------------------
+ if (voterInfoQuery.isError) {
+ return (
+
+
+
+
+
+
+ We couldn't load your voting options
+
+
+ The election lookup didn't respond. Your address is saved —
+ this is on our side.
+
+ void voterInfoQuery.refetch()}
+ style={s.primaryAction}
+ />
+
+
+ DON'T WAIT ON US
+
+ Your county election office has the same information and is always
+ authoritative.
+
+
+
+
+ );
+ }
+
+ // ---- Out of coverage --------------------------------------------------
+ if (unsupportedState) {
+ return (
+
+ setEditing(true)} />
+
+
+ Billion doesn't cover this state yet
+
+
+ We only have California voting information right now. Your state
+ election office has everything you need for this election.
+
+
+
+
+ );
+ }
+
+ const days = election ? daysUntil(election.electionDay) : 0;
+ const source = plan.source;
+
+ return (
+
+ {/* Which election, and where we're computing it from */}
+ {election && (
+
+
+
+
+ {formatDate(election.electionDay)}
+
+
+ {phase === "electionDay"
+ ? "· Today"
+ : phase === "ended"
+ ? "· Voting has closed"
+ : `· ${days} day${days === 1 ? "" : "s"} away`}
+
+
+ {election.name}
+
+
+
+
+
+
+ REGISTERED ADDRESS
+
+ {shortAddress(address ?? "")}
+
+
+ setEditing((v) => !v)}
+ hitSlop={12}
+ accessibilityRole="button"
+ >
+ Edit
+
+
+
+ Used only to look up your ballot and voting locations.
+
+
+ {editing && (
+
+ {
+ void setAddress(addr);
+ setEditing(false);
+ posthog.capture("voter_address_set", { is_update: true });
+ }}
+ />
+
+ )}
+
+
+
+ {/* Context for the address, not a step of its own. Billion holds no
+ registration data, so this asks the question the voter has rather
+ than announcing what we can't do — and the action always resolves,
+ because a prompt with no exit is worse than no prompt. */}
+
+
+
+ )}
+
+ {/* Election Day gets a pinned, unmissable banner. */}
+ {phase === "electionDay" && (
+
+
+
+ Today is Election Day
+
+ If you're in line when polls close, stay in line — you may
+ still vote.
+
+
+
+ )}
+
+ {phase === "ended" && (
+
+
+
+ This election has ended
+
+ Counting continues for several weeks after Election Day.
+
+
+
+ )}
+
+ {plan.mailOnly && phase !== "ended" && (
+
+
+
+ This is an all-mail election
+
+ Every registered voter is mailed a ballot and returns it by mail
+ or drop box.
+
+
+
+ )}
+
+ {/* Ways to vote — the list is the summary; no card restates it. */}
+ {phase !== "ended" && (
+
+ WAYS TO VOTE
+
+ {plan.methods.map((method) => (
+ toggleMethod(method.id)}
+ authorityName={source?.name}
+ locationFinderUrl={
+ source?.votingLocationFinderUrl ?? source?.electionInfoUrl
+ }
+ />
+ ))}
+
+
+ )}
+
+ {phase !== "ended" && plan.noLocationsPublished && (
+
+ VOTING LOCATIONS
+
+
+ )}
+
+ {/* Post-election: close the loop the voter was in, don't reset to zero. */}
+ {phase === "ended" && (
+
+ WHAT HAPPENS NOW
+
+ Ballots that met your county's return deadline are still being
+ counted. Your county election office can confirm whether yours was
+ accepted.
+
+
+ router.push("/(tabs)/elections")}
+ />
+
+ )}
+
+ {/* What to bring — one fact plus the authoritative link. */}
+ {phase !== "ended" && (
+
+ WHAT TO BRING
+
+
+
+ )}
+
+
+
+ );
+}
+
+/** Address row used by states that render before the election card exists. */
+function AddressRow({
+ address,
+ onEdit,
+}: {
+ address: string | null;
+ onEdit: () => void;
+}) {
+ if (!address) return null;
+ return (
+
+
+
+ REGISTERED ADDRESS
+
+ {shortAddress(address)}
+
+
+
+ Edit
+
+
+ );
+}
+
+/** Shared chrome: nav header, large display title, padded scroll. */
+function Screen({ children }: { children: ReactNode }) {
+ const router = useRouter();
+ return (
+
+ router.back()} />
+
+ How to Vote
+ {children}
+
+
+ );
+}
+
+const s = StyleSheet.create({
+ screen: { flex: 1, backgroundColor: planes.navy },
+ scroll: { flex: 1 },
+ scrollContent: { paddingHorizontal: 20, paddingBottom: 48 },
+ display: {
+ fontFamily: fontDisplay.bold,
+ fontSize: 34,
+ lineHeight: 38,
+ color: colors.white,
+ marginBottom: 18,
+ },
+ stack: { gap: 14 },
+
+ electionCard: { borderColor: hair[2] },
+ dayRow: { flexDirection: "row", alignItems: "center", gap: 7 },
+ dayText: {
+ fontFamily: fontBody.semibold,
+ fontSize: 13,
+ color: colors.green[500],
+ },
+ dayCountdown: {
+ fontFamily: fontBody.medium,
+ fontSize: 13,
+ color: colors.textSecondary,
+ },
+ electionName: {
+ fontFamily: fontEditorial.bold,
+ fontSize: 18,
+ lineHeight: 23,
+ color: colors.white,
+ marginTop: 6,
+ },
+ rule: { height: 1, backgroundColor: hair[2], marginVertical: 14 },
+
+ addrCard: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 11,
+ backgroundColor: planes.slate,
+ borderWidth: 1,
+ borderColor: hair[2],
+ borderRadius: 12,
+ paddingVertical: 12,
+ paddingHorizontal: 14,
+ },
+ addrRow: { flexDirection: "row", alignItems: "center", gap: 11 },
+ addrBody: { flex: 1, minWidth: 0 },
+ addrKicker: {
+ fontFamily: fontBody.medium,
+ fontSize: 11,
+ letterSpacing: 0.4,
+ color: colors.textSecondary,
+ },
+ addrText: {
+ fontFamily: fontBody.semibold,
+ fontSize: 13.5,
+ color: colors.white,
+ marginTop: 1,
+ },
+ addrEdit: {
+ fontFamily: fontBody.semibold,
+ fontSize: 13,
+ color: colors.bill,
+ },
+ editWrap: { marginTop: 12 },
+
+ banner: {
+ flexDirection: "row",
+ alignItems: "flex-start",
+ gap: 11,
+ padding: 14,
+ borderRadius: 12,
+ borderWidth: 1,
+ },
+ bannerToday: {
+ backgroundColor: "rgba(16,185,129,0.09)",
+ borderColor: "rgba(16,185,129,0.34)",
+ },
+ bannerInfo: {
+ backgroundColor: "rgba(74,124,255,0.08)",
+ borderColor: "rgba(74,124,255,0.30)",
+ },
+ bannerEnded: {
+ backgroundColor: planes.slate,
+ borderColor: hair[2],
+ },
+ bannerBody: { flex: 1, minWidth: 0 },
+ bannerTitle: {
+ fontFamily: fontBody.semibold,
+ fontSize: 13.5,
+ lineHeight: 18,
+ color: colors.white,
+ },
+ bannerDetail: {
+ fontFamily: fontBody.regular,
+ fontSize: 12.5,
+ lineHeight: 18,
+ color: "rgba(255,255,255,0.72)",
+ marginTop: 3,
+ },
+
+ methodSection: { gap: 0 },
+ methodList: { gap: 12 },
+
+ gapCard: { borderColor: hair[2] },
+ emptyIcon: {
+ width: 44,
+ height: 44,
+ borderRadius: 12,
+ backgroundColor: planes.surface,
+ alignItems: "center",
+ justifyContent: "center",
+ marginBottom: 13,
+ },
+ cardTitle: {
+ fontFamily: fontEditorial.bold,
+ fontSize: 16,
+ lineHeight: 21,
+ color: colors.white,
+ },
+ cardBody: {
+ fontFamily: fontBody.regular,
+ fontSize: 13.5,
+ lineHeight: 20,
+ color: "rgba(255,255,255,0.82)",
+ marginTop: 8,
+ },
+ primaryAction: { marginTop: 15 },
+ fineprint: {
+ fontFamily: fontBody.regular,
+ fontSize: 11.5,
+ lineHeight: 16,
+ color: colors.textSecondary,
+ marginTop: 11,
+ textAlign: "center",
+ },
+ fineprintLeft: {
+ fontFamily: fontBody.regular,
+ fontSize: 11.5,
+ lineHeight: 16,
+ color: colors.textSecondary,
+ marginTop: 9,
+ },
+
+ loadingCard: { flexDirection: "row", alignItems: "center", gap: 13 },
+ loadingCopy: { flex: 1, gap: 2 },
+ loadingTitle: {
+ fontFamily: fontBody.semibold,
+ fontSize: 14,
+ color: colors.white,
+ },
+ loadingSub: {
+ fontFamily: fontBody.regular,
+ fontSize: 12.5,
+ color: colors.textSecondary,
+ },
+});
diff --git a/apps/expo/src/app/local-elections.tsx b/apps/expo/src/app/local-elections.tsx
index 52dfebe1..81acf72d 100644
--- a/apps/expo/src/app/local-elections.tsx
+++ b/apps/expo/src/app/local-elections.tsx
@@ -2,41 +2,42 @@ import { ScrollView, StyleSheet, TouchableOpacity } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useRouter } from "expo-router";
import { FontAwesome } from "@expo/vector-icons";
-import { useQuery } from "@tanstack/react-query";
-import { KeyDatesSection } from "~/components/KeyDatesSection";
import { LocalBillsSection } from "~/components/LocalBillsSection";
-import { MyBallotSection } from "~/components/MyBallotSection";
-import { PollingPlacesSection } from "~/components/PollingPlacesSection";
import { RepsSection } from "~/components/RepsSection";
import { Text, View } from "~/components/Themed";
import { UpcomingMeetingsSection } from "~/components/UpcomingMeetingsSection";
import { useUserAddress } from "~/hooks/useUserAddress";
-import { colors, fontDisplay, fontSize, sp, useTheme } from "~/styles";
-import { trpc } from "~/utils/api";
-import { daysUntil } from "~/utils/dates";
+import {
+ colors,
+ fontBody,
+ fontDisplay,
+ fontSize,
+ rd,
+ sp,
+ useTheme,
+} from "~/styles";
+import { coveredJurisdiction } from "~/utils/local-government";
/**
- * "Where & How to Vote" — the civic logistics hub. This is intentionally NOT a
- * second copy of the ballot (that lives on the Elections tab). It answers the
- * questions the ballot can't: where do I vote, when, who represents me, and
- * what's my city/county doing right now (local bills + meetings).
+ * "Your Local Government" — city and county activity: local bills, upcoming
+ * public meetings, and who represents you.
+ *
+ * This used to be "Where & How to Vote" and also carried polling places, key
+ * dates, and an address card. All three moved to the How to Vote screen, which
+ * is now the single home for voting logistics. What's left is the part that
+ * was never about voting.
+ *
+ * Coverage note: Legistar is wired for San Jose, Santa Clara County, and
+ * Sunnyvale only, and the router merges all three regardless of the reader's
+ * address — so the header states the coverage instead of implying it's theirs.
*/
-export default function LocalElectionsScreen() {
+export default function LocalGovernmentScreen() {
const { theme } = useTheme();
const router = useRouter();
const insets = useSafeAreaInsets();
- const { address, setAddress, clearAddress } = useUserAddress();
-
- const electionsQuery = useQuery(trpc.civic.getElections.queryOptions());
- const upcomingElection = electionsQuery.data
- ?.filter((e) => daysUntil(e.electionDay) >= 0)
- .sort((a, b) => a.electionDay.localeCompare(b.electionDay))[0];
-
- const voterInfoQuery = useQuery({
- ...trpc.civic.getVoterInfo.queryOptions({ address: address ?? "" }),
- enabled: !!address,
- });
+ const { address } = useUserAddress();
+ const covered = coveredJurisdiction(address);
return (
@@ -47,7 +48,9 @@ export default function LocalElectionsScreen() {
>
- Where & How to Vote
+
+ {covered ? "Your Local Government" : "Local Government"}
+
@@ -56,31 +59,25 @@ export default function LocalElectionsScreen() {
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
- router.push("/(tabs)/elections")}
- />
-
-
-
- {upcomingElection && (
-
+ {/* Coverage is a scope, not a failure — say which governments these
+ are rather than letting the reader assume they're theirs. */}
+ {!covered && (
+
+
+ Billion covers three Bay Area governments
+
+
+ San Jose, Santa Clara County, and Sunnyvale. Your area isn't
+ one of them yet.
+
+
)}
-
-
+
+
);
@@ -115,4 +112,25 @@ const styles = StyleSheet.create({
scrollContent: {
paddingBottom: sp[5],
},
+ coverage: {
+ marginHorizontal: sp[4],
+ marginBottom: sp[6],
+ padding: sp[4],
+ borderRadius: rd.lg,
+ borderWidth: 1,
+ borderColor: "rgba(74,124,255,0.30)",
+ backgroundColor: "rgba(74,124,255,0.08)",
+ },
+ coverageTitle: {
+ fontFamily: fontBody.semibold,
+ fontSize: fontSize.sm,
+ color: colors.white,
+ },
+ coverageBody: {
+ fontFamily: fontBody.regular,
+ fontSize: fontSize.xs,
+ lineHeight: 18,
+ color: "rgba(255,255,255,0.72)",
+ marginTop: sp[1],
+ },
});
diff --git a/apps/expo/src/components/ElectionHero.tsx b/apps/expo/src/components/ElectionHero.tsx
index 192d7138..58d908b4 100644
--- a/apps/expo/src/components/ElectionHero.tsx
+++ b/apps/expo/src/components/ElectionHero.tsx
@@ -12,7 +12,7 @@ import type { Election } from "@acme/api";
import { Text } from "~/components/Themed";
import { Icon } from "~/components/ui";
import { colors, fontBody, hair, planes } from "~/styles";
-import { daysUntil, monthDay, shiftDays } from "~/utils/dates";
+import { daysUntil, monthDay } from "~/utils/dates";
import {
electionExplainer,
electionType,
@@ -28,21 +28,15 @@ export function ElectionHero({ election }: ElectionHeroProps) {
const type = electionType(election.name);
const days = daysUntil(election.electionDay);
- // Key dates. TODO(backend): exact per-jurisdiction registration / VBM dates;
- // these offsets approximate a typical California timeline.
+ // Only Election Day is shown here, and only because Google Civic actually
+ // returns it. The "Registration closes" and "Ballots mailed" rows that used
+ // to sit alongside it were computed as electionDay-15 and electionDay-8 —
+ // a plausible California timeline presented as fact, with no source and no
+ // hedging. Deadlines vary by state and county and change between cycles, so
+ // an offset is a guess, not a deadline. Voting logistics now live on the How
+ // to Vote screen, which renders an honest "not published" state rather than
+ // inventing a date.
const dates = [
- {
- icon: "clock" as const,
- label: "Registration closes",
- value: monthDay(shiftDays(election.electionDay, -15)),
- accent: colors.yellow[500],
- },
- {
- icon: "calendar" as const,
- label: "Ballots mailed",
- value: monthDay(shiftDays(election.electionDay, -8)),
- accent: colors.textSecondary,
- },
{
icon: "flag" as const,
label: "Election Day",
diff --git a/apps/expo/src/components/HowToVoteEntryCard.tsx b/apps/expo/src/components/HowToVoteEntryCard.tsx
new file mode 100644
index 00000000..f1b9642f
--- /dev/null
+++ b/apps/expo/src/components/HowToVoteEntryCard.tsx
@@ -0,0 +1,100 @@
+/**
+ * HowToVoteEntryCard — the Elections-tab entry into voting logistics.
+ *
+ * Sits directly under the election hero, 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 a voter who came only for logistics never has to scroll past
+ * a single contest to find it.
+ *
+ * It renders in every state, including with no address, because a section that
+ * silently disappears reads as a feature that doesn't exist.
+ */
+import { StyleSheet, TouchableOpacity, View } from "react-native";
+
+import type { ElectionPhase, VotingPlan } from "~/utils/voting";
+import { Text } from "~/components/Themed";
+import { Icon } from "~/components/ui";
+import { colors, fontBody, fontEditorial, hair, planes } from "~/styles";
+import { entryCardSubtitle } from "~/utils/voting";
+
+export function HowToVoteEntryCard({
+ hasAddress,
+ plan,
+ phase,
+ onPress,
+}: {
+ hasAddress: boolean;
+ plan?: VotingPlan;
+ phase: ElectionPhase;
+ onPress: () => void;
+}) {
+ const subtitle = entryCardSubtitle(hasAddress, plan, phase);
+ const isElectionDay = phase === "electionDay";
+
+ return (
+
+
+
+
+
+
+ How to Vote
+ {subtitle}
+
+
+
+
+ );
+}
+
+const s = StyleSheet.create({
+ card: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 13,
+ paddingVertical: 15,
+ paddingHorizontal: 16,
+ minHeight: 60,
+ backgroundColor: planes.slate,
+ borderWidth: 1,
+ borderColor: hair[2],
+ borderRadius: 16,
+ },
+ cardToday: { borderColor: "rgba(16,185,129,0.34)" },
+ iconTile: {
+ width: 44,
+ height: 44,
+ borderRadius: 12,
+ backgroundColor: planes.surface,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ body: { flex: 1, minWidth: 0, gap: 3 },
+ title: {
+ fontFamily: fontEditorial.bold,
+ fontSize: 16,
+ lineHeight: 19,
+ color: colors.white,
+ },
+ subtitle: {
+ fontFamily: fontBody.regular,
+ fontSize: 12.5,
+ lineHeight: 17,
+ color: colors.textSecondary,
+ },
+});
diff --git a/apps/expo/src/components/KeyDatesSection.tsx b/apps/expo/src/components/KeyDatesSection.tsx
index 09bbdaf7..38dd0a2e 100644
--- a/apps/expo/src/components/KeyDatesSection.tsx
+++ b/apps/expo/src/components/KeyDatesSection.tsx
@@ -1,100 +1,55 @@
-import { ScrollView, StyleSheet } from "react-native";
+/**
+ * KeyDatesSection — the election date, and only the election date.
+ *
+ * This section previously rendered three cards: "Registration Deadline" at
+ * electionDay-15 and "Early Voting Starts" at electionDay-29, alongside the
+ * real Election Day. Both were pure offset arithmetic — no source, no hedging,
+ * and styled identically to the date we actually get from Google Civic. Those
+ * offsets approximate one California cycle and are wrong for most jurisdictions
+ * and most years, so they've been removed rather than relabelled.
+ *
+ * Voting deadlines belong on the How to Vote screen, which shows an explicit
+ * "not published" state when Billion has no sourced date.
+ */
+import { StyleSheet } from "react-native";
import { Text, View } from "~/components/Themed";
-import { fontBody, fontEditorial, fontSize, rd, sp, useTheme } from "~/styles";
+import {
+ colors,
+ fontBody,
+ fontEditorial,
+ fontSize,
+ rd,
+ sp,
+ useTheme,
+} from "~/styles";
import { daysUntil, formatDate } from "~/utils/dates";
-interface KeyDate {
- label: string;
- date: string;
-}
-
interface KeyDatesSectionProps {
electionDate: string;
}
export function KeyDatesSection({ electionDate }: KeyDatesSectionProps) {
const { theme } = useTheme();
-
- const electionDateObj = new Date(electionDate);
- const registrationDeadline = new Date(electionDateObj);
- registrationDeadline.setDate(registrationDeadline.getDate() - 15);
-
- const earlyVotingStart = new Date(electionDateObj);
- earlyVotingStart.setDate(earlyVotingStart.getDate() - 29);
-
- const dates: KeyDate[] = [
- {
- label: "Registration Deadline",
- date: registrationDeadline.toISOString().split("T")[0] ?? "",
- },
- {
- label: "Early Voting Starts",
- date: earlyVotingStart.toISOString().split("T")[0] ?? "",
- },
- {
- label: "Election Day",
- date: electionDate,
- },
- ];
+ const days = daysUntil(electionDate);
return (
- Key Dates
-
- {dates.map((item, index) => {
- const days = daysUntil(item.date);
- const isPassed = days < 0;
- const isNext =
- !isPassed &&
- dates.findIndex((d) => daysUntil(d.date) >= 0) === index;
-
- return (
-
-
- {item.label}
-
-
- {formatDate(item.date)}
-
-
- {isPassed
- ? "Passed"
- : days === 0
- ? "Today!"
- : `in ${days} days`}
-
-
- );
- })}
-
+ Election Day
+
+ {formatDate(electionDate)}
+
+ {days < 0
+ ? "Voting has closed"
+ : days === 0
+ ? "Today"
+ : `in ${days} day${days === 1 ? "" : "s"}`}
+
+
);
}
-const colors = {
- white: "#FFFFFF",
- civicBlue: "#4A7CFF",
- textMuted: "#8A8FA0",
-};
-
const styles = StyleSheet.create({
container: {
marginBottom: sp[6],
@@ -106,42 +61,20 @@ const styles = StyleSheet.create({
marginHorizontal: sp[4],
marginBottom: sp[3],
},
- scrollContent: {
- paddingHorizontal: sp[4],
- gap: sp[3],
- },
card: {
+ marginHorizontal: sp[4],
padding: sp[4],
borderRadius: rd.md,
- minWidth: 140,
- },
- cardHighlight: {
- borderWidth: 2,
- borderColor: colors.civicBlue,
- },
- label: {
- fontFamily: fontBody.medium,
- fontSize: fontSize.xs,
- color: colors.textMuted,
- marginBottom: sp[2],
},
date: {
fontFamily: fontBody.semibold,
- fontSize: fontSize.sm,
+ fontSize: fontSize.base,
color: colors.white,
- marginBottom: sp[2],
},
countdown: {
fontFamily: fontBody.regular,
- fontSize: fontSize.xs,
- color: colors.textMuted,
- },
- countdownHighlight: {
- color: colors.civicBlue,
- fontFamily: fontBody.semibold,
- },
- textMuted: {
- color: colors.textMuted,
- opacity: 0.6,
+ fontSize: fontSize.sm,
+ color: colors.textSecondary,
+ marginTop: sp[1],
},
});
diff --git a/apps/expo/src/components/LocalGovernmentCard.tsx b/apps/expo/src/components/LocalGovernmentCard.tsx
new file mode 100644
index 00000000..f7a0c6e0
--- /dev/null
+++ b/apps/expo/src/components/LocalGovernmentCard.tsx
@@ -0,0 +1,95 @@
+/**
+ * LocalGovernmentCard — the Browse-tab entry into city and county activity.
+ *
+ * Lives under the jurisdiction row, where the reader is already being asked
+ * "which government am I looking at". Local government is the same question
+ * one level down, which is why this sits in Browse rather than on the
+ * Elections tab: council meetings aren't voting.
+ *
+ * The possessive is earned. "Your Local Government" only when the reader's
+ * address is inside a jurisdiction Billion covers; otherwise it names the
+ * scope, because the underlying feed serves Bay Area content to everyone.
+ */
+import { StyleSheet, TouchableOpacity, View } from "react-native";
+
+import { Text } from "~/components/Themed";
+import { Icon } from "~/components/ui";
+import { colors, fontBody, fontEditorial, hair, planes } from "~/styles";
+import { coverageSummary, coveredJurisdiction } from "~/utils/local-government";
+
+export function LocalGovernmentCard({
+ address,
+ onPress,
+}: {
+ address: string | null;
+ onPress: () => void;
+}) {
+ const covered = coveredJurisdiction(address);
+ const title = covered ? "Your Local Government" : "Local Government";
+ const subtitle = covered
+ ? `${covered.name} · bills and upcoming meetings`
+ : coverageSummary();
+
+ return (
+
+
+
+
+
+
+ {title}
+
+ {subtitle}
+
+
+
+
+
+ );
+}
+
+const s = StyleSheet.create({
+ card: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 13,
+ paddingVertical: 15,
+ paddingHorizontal: 16,
+ minHeight: 60,
+ backgroundColor: planes.slate,
+ borderWidth: 1,
+ borderColor: hair[2],
+ borderRadius: 16,
+ marginBottom: 16,
+ },
+ iconTile: {
+ width: 44,
+ height: 44,
+ borderRadius: 12,
+ backgroundColor: planes.surface,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ body: { flex: 1, minWidth: 0, gap: 3 },
+ title: {
+ fontFamily: fontEditorial.bold,
+ fontSize: 16,
+ lineHeight: 19,
+ color: colors.white,
+ },
+ subtitle: {
+ fontFamily: fontBody.regular,
+ fontSize: 12.5,
+ lineHeight: 17,
+ color: colors.textSecondary,
+ },
+});
diff --git a/apps/expo/src/components/how-to-vote/MethodCard.tsx b/apps/expo/src/components/how-to-vote/MethodCard.tsx
new file mode 100644
index 00000000..a81cd8ce
--- /dev/null
+++ b/apps/expo/src/components/how-to-vote/MethodCard.tsx
@@ -0,0 +1,224 @@
+/**
+ * MethodCard — one way to vote, collapsed to a scannable row and expanded to a
+ * numbered checklist plus the locations it applies to.
+ *
+ * Collapsed is the default because the whole point of the screen is that a
+ * voter can see every option at once and pick one. Expanding is what reveals
+ * procedure; only one card is open at a time (owned by the parent).
+ */
+import { useState } from "react";
+import { StyleSheet, TouchableOpacity, View } from "react-native";
+
+import type { IconName } from "~/components/ui";
+import type { VotingMethod, VotingMethodId } from "~/utils/voting";
+import { Text } from "~/components/Themed";
+import { Icon } from "~/components/ui";
+import { colors, fontBody, fontEditorial, hair, planes } from "~/styles";
+import {
+ LinkRow,
+ LocationList,
+ StatusChip,
+ StepList,
+ UnavailableNote,
+} from "./parts";
+
+const METHOD_ICON: Record = {
+ mail: "mail",
+ dropBox: "inbox",
+ earlyInPerson: "calendar",
+ electionDay: "pin",
+};
+
+const LOCATION_ACCENT: Record = {
+ mail: colors.bill,
+ dropBox: colors.green[500],
+ earlyInPerson: colors.bill,
+ electionDay: colors.green[500],
+};
+
+/** Kicker above the location list, per method. */
+const LOCATION_TITLE: Record = {
+ mail: "WHERE TO SEND IT",
+ dropBox: "DROP BOX LOCATIONS",
+ earlyInPerson: "EARLY VOTE SITES",
+ electionDay: "POLLING PLACES",
+};
+
+export function MethodCard({
+ method,
+ expanded,
+ onToggle,
+ authorityName,
+ locationFinderUrl,
+}: {
+ method: VotingMethod;
+ expanded: boolean;
+ onToggle: () => void;
+ /** Named above the steps, so whose words they are is clear before reading. */
+ authorityName?: string;
+ /** Official location finder — the fallback when nothing is published. */
+ locationFinderUrl?: string;
+}) {
+ const [showAllLocations, setShowAllLocations] = useState(false);
+ const dimmed = method.status === "limited" || method.status === "closed";
+
+ return (
+
+
+
+
+
+
+ {method.title}
+ {method.subtitle ? (
+ {method.subtitle}
+ ) : null}
+
+
+
+
+
+
+
+ {expanded && (
+
+ {/* Steps are a summary of the authority's instructions, never
+ Billion's own advice — so they only render when we can name and
+ link the source they came from. */}
+ {method.steps.length > 0 && (
+ <>
+
+ {authorityName
+ ? `SUMMARIZED FROM ${authorityName.toUpperCase()}`
+ : "SUMMARIZED FROM YOUR ELECTION OFFICE"}
+
+
+
+ >
+ )}
+
+ {method.steps.length === 0 && (
+
+ )}
+
+ {method.locations.length > 0 ? (
+ <>
+
+ {LOCATION_TITLE[method.id]}
+ setShowAllLocations((v) => !v)}
+ />
+ >
+ ) : method.status === "unknown" ? (
+ <>
+
+
+
+ >
+ ) : null}
+
+ )}
+
+ );
+}
+
+const s = StyleSheet.create({
+ card: {
+ backgroundColor: planes.slate,
+ borderWidth: 1,
+ borderColor: hair[1],
+ borderRadius: 16,
+ overflow: "hidden",
+ },
+ cardOpen: { borderColor: hair[3] },
+ cardDimmed: { opacity: 0.62 },
+ head: {
+ flexDirection: "row",
+ alignItems: "flex-start",
+ gap: 13,
+ paddingVertical: 15,
+ paddingHorizontal: 16,
+ minHeight: 60,
+ },
+ iconTile: {
+ width: 38,
+ height: 38,
+ borderRadius: 11,
+ backgroundColor: planes.surface,
+ alignItems: "center",
+ justifyContent: "center",
+ marginTop: 1,
+ },
+ iconTileOpen: { backgroundColor: planes.hi },
+ body: { flex: 1, minWidth: 0, gap: 4 },
+ title: {
+ fontFamily: fontEditorial.bold,
+ fontSize: 16,
+ lineHeight: 19,
+ color: colors.white,
+ },
+ titleDimmed: { color: colors.textSecondary },
+ subtitle: {
+ fontFamily: fontBody.regular,
+ fontSize: 12.5,
+ lineHeight: 17,
+ color: colors.textSecondary,
+ },
+ chips: { flexDirection: "row", flexWrap: "wrap", gap: 6, marginTop: 3 },
+ chevron: { marginTop: 11 },
+ open: {
+ paddingHorizontal: 16,
+ paddingBottom: 16,
+ paddingTop: 16,
+ borderTopWidth: 1,
+ borderTopColor: hair[1],
+ },
+ rule: { height: 1, backgroundColor: hair[2], marginVertical: 14 },
+ kicker: {
+ fontFamily: fontBody.semibold,
+ fontSize: 11,
+ letterSpacing: 1,
+ color: colors.textSecondary,
+ marginBottom: 10,
+ },
+});
diff --git a/apps/expo/src/components/how-to-vote/parts.tsx b/apps/expo/src/components/how-to-vote/parts.tsx
new file mode 100644
index 00000000..4df94ed3
--- /dev/null
+++ b/apps/expo/src/components/how-to-vote/parts.tsx
@@ -0,0 +1,493 @@
+/**
+ * Shared atoms for the How to Vote screen.
+ *
+ * These are the pieces the design spec calls out as reusable: the status chip
+ * (icon + word + colour, in that order of importance), the fact row, the
+ * numbered checklist, the location row, and the official-source footer.
+ *
+ * Status is never communicated by colour alone — every chip carries a word and
+ * an icon so the screen survives greyscale, colour-blindness, and VoiceOver.
+ */
+import { Linking, StyleSheet, TouchableOpacity, View } from "react-native";
+
+import type { Address, PollingLocation } from "@acme/api";
+
+import type { IconName } from "~/components/ui";
+import type { MethodChip, OfficialSource, VotingStep } from "~/utils/voting";
+import { Text } from "~/components/Themed";
+import { Card, Icon } from "~/components/ui";
+import { colors, fontBody, hair, planes } from "~/styles";
+import { formatCivicAddress } from "~/utils/voting";
+
+/** Civic returns every county vote center under one address; cap each group. */
+const COLLAPSED_COUNT = 3;
+
+const TONE_COLOR: Record = {
+ positive: colors.green[500],
+ urgent: colors.yellow[500],
+ neutral: colors.textSecondary,
+ negative: colors.red[500],
+};
+
+const TONE_BORDER: Record = {
+ positive: "rgba(16,185,129,0.34)",
+ urgent: "rgba(245,158,11,0.34)",
+ neutral: hair[2],
+ negative: "rgba(239,68,68,0.34)",
+};
+
+/* ---------- StatusChip ---------- */
+
+export function StatusChip({ chip }: { chip: MethodChip }) {
+ const color = TONE_COLOR[chip.tone];
+ return (
+
+
+ {chip.label}
+
+ );
+}
+
+/* ---------- FactRow — deadlines, requirements, registration ---------- */
+
+export function FactRow({
+ icon,
+ iconColor = colors.textSecondary,
+ label,
+ value,
+ action,
+}: {
+ icon: IconName;
+ iconColor?: string;
+ label: string;
+ value?: string;
+ action?: { label: string; onPress: () => void };
+}) {
+ return (
+
+
+
+ {label}
+ {value ? {value} : null}
+
+ {action && (
+
+ {action.label}
+
+ )}
+
+ );
+}
+
+/* ---------- StepList — numbered checklist ---------- */
+
+export function StepList({ steps }: { steps: VotingStep[] }) {
+ return (
+
+ {steps.map((step, i) => (
+
+
+ {i + 1}
+
+
+ {step.title}
+ {step.detail ? (
+ {step.detail}
+ ) : null}
+
+
+ ))}
+
+ );
+}
+
+/* ---------- LocationRow ---------- */
+
+/** Open the platform maps app with the location as a search query. */
+export function openDirections(loc: PollingLocation) {
+ const q = encodeURIComponent(
+ [loc.name ?? loc.address.locationName, formatCivicAddress(loc.address)]
+ .filter(Boolean)
+ .join(" "),
+ );
+ void Linking.openURL(`https://maps.apple.com/?q=${q}`);
+}
+
+export function LocationRow({
+ loc,
+ accent,
+}: {
+ loc: PollingLocation;
+ accent: string;
+}) {
+ const name = loc.name ?? loc.address.locationName ?? "Voting location";
+ const address: Address = loc.address;
+ return (
+
+
+
+
+ {name}
+
+ {formatCivicAddress(address)}
+
+
+
+ {loc.pollingHours ?? "Hours not published"}
+
+
+ {loc.notes ? {loc.notes} : null}
+ openDirections(loc)}
+ accessibilityRole="button"
+ accessibilityLabel={`Get directions to ${name}`}
+ accessibilityHint="Opens in Maps"
+ >
+
+ Get directions
+
+
+
+ );
+}
+
+/** A titled group of locations, collapsed to COLLAPSED_COUNT with a toggle. */
+export function LocationList({
+ locations,
+ accent,
+ expanded,
+ onToggle,
+}: {
+ locations: PollingLocation[];
+ accent: string;
+ expanded: boolean;
+ onToggle: () => void;
+}) {
+ const overflow = locations.length - COLLAPSED_COUNT;
+ const visible =
+ expanded || overflow <= 0 ? locations : locations.slice(0, COLLAPSED_COUNT);
+
+ return (
+
+ {visible.map((loc, i) => (
+
+ ))}
+ {overflow > 0 && (
+
+
+ {expanded ? "Show fewer" : `Show all ${locations.length}`}
+
+
+
+ )}
+
+ );
+}
+
+/* ---------- LinkRow — an explicit, labelled hand-off out of the app ---------- */
+
+export function LinkRow({
+ label,
+ url,
+ onPress,
+}: {
+ label: string;
+ url?: string;
+ onPress?: () => void;
+}) {
+ if (!url && !onPress) return null;
+ return (
+ {
+ if (onPress) return onPress();
+ if (url) void Linking.openURL(url);
+ }}
+ accessibilityRole="link"
+ accessibilityLabel={label}
+ accessibilityHint="Opens in your browser"
+ >
+ {label}
+
+
+ );
+}
+
+/* ---------- UnavailableNote — a named gap, never a silent one ---------- */
+
+export function UnavailableNote({
+ title,
+ body,
+}: {
+ title: string;
+ body: string;
+}) {
+ return (
+
+
+
+ {title}
+ {body}
+
+
+ );
+}
+
+/* ---------- SourceFooter — the last card on every state ---------- */
+
+export function SourceFooter({
+ source,
+ note,
+}: {
+ source?: OfficialSource;
+ /** Overrides the default verification line (e.g. "Last checked …"). */
+ note?: string;
+}) {
+ return (
+
+
+
+ OFFICIAL INFORMATION
+
+
+ {source?.name ?? "Your state or county election office"}
+
+
+ {note ??
+ "Locations and contacts come from Google Civic. Deadlines aren't verified in Billion yet — confirm them with your county."}
+
+
+
+
+ );
+}
+
+const s = StyleSheet.create({
+ chip: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 5,
+ minHeight: 22,
+ paddingHorizontal: 8,
+ paddingVertical: 3,
+ borderRadius: 6,
+ borderWidth: 1,
+ backgroundColor: planes.surface,
+ alignSelf: "flex-start",
+ },
+ chipText: { fontFamily: fontBody.semibold, fontSize: 11 },
+
+ factRow: { flexDirection: "row", alignItems: "flex-start", gap: 9 },
+ factIcon: { marginTop: 2 },
+ factBody: { flex: 1, minWidth: 0 },
+ factLabel: {
+ fontFamily: fontBody.semibold,
+ fontSize: 12.5,
+ lineHeight: 17,
+ color: colors.white,
+ },
+ factValue: {
+ fontFamily: fontBody.regular,
+ fontSize: 12.5,
+ lineHeight: 18,
+ color: colors.textSecondary,
+ marginTop: 1,
+ },
+ factAction: {
+ fontFamily: fontBody.semibold,
+ fontSize: 13,
+ color: colors.bill,
+ paddingLeft: 8,
+ },
+
+ steps: { gap: 13 },
+ step: { flexDirection: "row", gap: 10 },
+ stepBadge: {
+ width: 22,
+ height: 22,
+ borderRadius: 7,
+ backgroundColor: planes.surface,
+ borderWidth: 1,
+ borderColor: hair[2],
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ stepNum: { fontFamily: fontBody.bold, fontSize: 11, color: colors.white },
+ stepBody: { flex: 1, minWidth: 0 },
+ stepTitle: {
+ fontFamily: fontBody.semibold,
+ fontSize: 13.5,
+ lineHeight: 19,
+ color: colors.white,
+ },
+ stepDetail: {
+ fontFamily: fontBody.regular,
+ fontSize: 12.5,
+ lineHeight: 18,
+ color: colors.textSecondary,
+ marginTop: 2,
+ },
+
+ locList: { gap: 10 },
+ loc: {
+ flexDirection: "row",
+ gap: 11,
+ backgroundColor: planes.surface,
+ borderRadius: 12,
+ padding: 13,
+ },
+ locSpine: { width: 3, borderRadius: 2 },
+ locBody: { flex: 1, minWidth: 0, gap: 3 },
+ locName: {
+ fontFamily: fontBody.semibold,
+ fontSize: 13.5,
+ lineHeight: 18,
+ color: colors.white,
+ },
+ locAddr: {
+ fontFamily: fontBody.regular,
+ fontSize: 12.5,
+ lineHeight: 17,
+ color: colors.textSecondary,
+ },
+ locHoursRow: { flexDirection: "row", alignItems: "center", gap: 6 },
+ locHours: {
+ flex: 1,
+ fontFamily: fontBody.regular,
+ fontSize: 12,
+ lineHeight: 16,
+ color: colors.textSecondary,
+ },
+ locNotes: {
+ fontFamily: fontBody.regular,
+ fontSize: 12,
+ lineHeight: 17,
+ color: "rgba(255,255,255,0.7)",
+ },
+ dirBtn: {
+ marginTop: 7,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 6,
+ minHeight: 36,
+ paddingHorizontal: 12,
+ borderRadius: 9,
+ backgroundColor: planes.hi,
+ borderWidth: 1,
+ borderColor: hair[2],
+ alignSelf: "flex-start",
+ },
+ dirText: {
+ fontFamily: fontBody.semibold,
+ fontSize: 12.5,
+ color: colors.bill,
+ },
+ showAll: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 6,
+ alignSelf: "flex-start",
+ minHeight: 44,
+ paddingHorizontal: 2,
+ },
+ showAllText: {
+ fontFamily: fontBody.semibold,
+ fontSize: 13,
+ color: colors.bill,
+ },
+
+ linkRow: {
+ marginTop: 12,
+ minHeight: 44,
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ gap: 10,
+ paddingHorizontal: 14,
+ paddingVertical: 11,
+ borderRadius: 11,
+ backgroundColor: planes.surface,
+ borderWidth: 1,
+ borderColor: hair[2],
+ },
+ linkText: {
+ flex: 1,
+ fontFamily: fontBody.semibold,
+ fontSize: 13.5,
+ color: colors.bill,
+ },
+
+ unavailable: {
+ flexDirection: "row",
+ gap: 10,
+ padding: 12,
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: hair[2],
+ },
+ unavailableTitle: {
+ fontFamily: fontBody.semibold,
+ fontSize: 12.5,
+ lineHeight: 17,
+ color: colors.white,
+ },
+ unavailableBody: {
+ fontFamily: fontBody.regular,
+ fontSize: 12,
+ lineHeight: 17,
+ color: colors.textSecondary,
+ marginTop: 2,
+ },
+
+ source: { borderColor: hair[2] },
+ sourceHead: { flexDirection: "row", alignItems: "center", gap: 9 },
+ sourceKicker: {
+ fontFamily: fontBody.bold,
+ fontSize: 11,
+ letterSpacing: 0.9,
+ color: colors.textSecondary,
+ },
+ sourceName: {
+ fontFamily: fontBody.semibold,
+ fontSize: 14,
+ lineHeight: 19,
+ color: colors.white,
+ marginTop: 10,
+ },
+ sourceVerified: {
+ fontFamily: fontBody.regular,
+ fontSize: 12,
+ lineHeight: 17,
+ color: colors.textSecondary,
+ marginTop: 5,
+ },
+});
diff --git a/apps/expo/src/components/ui/Icon.tsx b/apps/expo/src/components/ui/Icon.tsx
index 7e78ff48..8e55abe1 100644
--- a/apps/expo/src/components/ui/Icon.tsx
+++ b/apps/expo/src/components/ui/Icon.tsx
@@ -31,6 +31,9 @@ type IconName =
| "help"
| "message"
| "info"
+ | "alert"
+ | "mail"
+ | "inbox"
| "lock"
| "block"
| "doc"
@@ -80,6 +83,9 @@ const MAP: Record = {
help: { family: "feather", name: "help-circle" },
message: { family: "feather", name: "message-square" },
info: { family: "feather", name: "info" },
+ alert: { family: "feather", name: "alert-triangle" },
+ mail: { family: "feather", name: "mail" },
+ inbox: { family: "feather", name: "inbox" },
lock: { family: "feather", name: "lock" },
block: { family: "feather", name: "slash" },
doc: { family: "feather", name: "file-text" },
diff --git a/apps/expo/src/utils/dates.ts b/apps/expo/src/utils/dates.ts
index 65fd1e43..8ef56977 100644
--- a/apps/expo/src/utils/dates.ts
+++ b/apps/expo/src/utils/dates.ts
@@ -28,9 +28,9 @@ export function monthDay(dateString: string): string {
});
}
-/** ISO date string `days` before/after the given date. */
-export function shiftDays(dateString: string, days: number): string {
- const d = new Date(dateString);
- d.setDate(d.getDate() + days);
- return d.toISOString();
-}
+// NOTE: `shiftDays` used to live here. Its only callers were ElectionHero and
+// KeyDatesSection, which used it to synthesize registration and vote-by-mail
+// deadlines as fixed offsets from Election Day and render them as fact. Those
+// dates are jurisdiction-specific and were never sourced, so both callers and
+// this helper are gone. Deadlines belong to an official source or to the
+// "not published" state on How to Vote — not to arithmetic.
diff --git a/apps/expo/src/utils/local-government.test.ts b/apps/expo/src/utils/local-government.test.ts
new file mode 100644
index 00000000..e1dd6d00
--- /dev/null
+++ b/apps/expo/src/utils/local-government.test.ts
@@ -0,0 +1,40 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { coverageSummary, coveredJurisdiction } from "./local-government";
+
+void test("recognises each covered jurisdiction from a stored address", () => {
+ assert.equal(
+ coveredJurisdiction("200 E Santa Clara St, San Jose, CA, USA")?.id,
+ "sanjose",
+ );
+ assert.equal(
+ coveredJurisdiction("70 W Hedding St, Santa Clara County, CA")?.id,
+ "santaclara",
+ );
+ assert.equal(
+ coveredJurisdiction("456 W Olive Ave, Sunnyvale, CA 94086")?.id,
+ "sunnyvale",
+ );
+});
+
+void test("matches San José with its accent", () => {
+ assert.equal(
+ coveredJurisdiction("1 N Market St, San José, CA")?.id,
+ "sanjose",
+ );
+});
+
+void test("returns nothing outside coverage — the common, non-error case", () => {
+ // A Sacramento reader must never be told San Jose is "your" local government.
+ assert.equal(coveredJurisdiction("1414 K Street, Sacramento, CA"), undefined);
+ assert.equal(coveredJurisdiction(null), undefined);
+ assert.equal(coveredJurisdiction(""), undefined);
+});
+
+void test("coverage summary reads as a scope, not an apology", () => {
+ assert.equal(
+ coverageSummary(),
+ "San Jose, Santa Clara County, and Sunnyvale",
+ );
+});
diff --git a/apps/expo/src/utils/local-government.ts b/apps/expo/src/utils/local-government.ts
new file mode 100644
index 00000000..3ccb68d2
--- /dev/null
+++ b/apps/expo/src/utils/local-government.ts
@@ -0,0 +1,54 @@
+/**
+ * Which local government, if any, Billion actually covers for a reader.
+ *
+ * `legistar.getLocalBills` / `getMeetings` are wired to San Jose, Santa Clara
+ * County, and Sunnyvale, and the router merges all three regardless of who is
+ * asking. That means the content is real but frequently isn't *yours* — a
+ * Sacramento reader sees San Jose council meetings.
+ *
+ * This resolves the reader's stored address against that coverage so the UI
+ * can earn the word "your" instead of assuming it. Deliberately a string match
+ * on the address: there is no address→jurisdiction service today, and a cheap
+ * honest check beats an expensive wrong one. Broader coverage is issue #275;
+ * a real resolver belongs with the local-government work in #282.
+ */
+
+/** The jurisdictions Legistar is wired for. Mirrors the tRPC router's enum. */
+export const COVERED_JURISDICTIONS = [
+ { id: "sanjose", name: "San Jose", match: /\bsan\s+jos[eé](?![a-z])/i },
+ {
+ id: "santaclara",
+ name: "Santa Clara County",
+ match: /\bsanta\s+clara\b/i,
+ },
+ { id: "sunnyvale", name: "Sunnyvale", match: /\bsunnyvale\b/i },
+] as const;
+
+export type CoveredJurisdictionId =
+ (typeof COVERED_JURISDICTIONS)[number]["id"];
+
+export interface CoveredJurisdiction {
+ id: CoveredJurisdictionId;
+ name: string;
+}
+
+/**
+ * The covered jurisdiction containing this address, or `undefined`.
+ *
+ * `undefined` is the common case and is not an error — Bay-Area-only coverage
+ * is intentional. Callers should say which governments they *do* cover rather
+ * than apologising or hiding the section.
+ */
+export function coveredJurisdiction(
+ address: string | null | undefined,
+): CoveredJurisdiction | undefined {
+ if (!address) return undefined;
+ const found = COVERED_JURISDICTIONS.find((j) => j.match.test(address));
+ return found ? { id: found.id, name: found.name } : undefined;
+}
+
+/** "San Jose, Santa Clara County, and Sunnyvale" — for coverage copy. */
+export function coverageSummary(): string {
+ const names = COVERED_JURISDICTIONS.map((j) => j.name);
+ return `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`;
+}
diff --git a/apps/expo/src/utils/voting.test.ts b/apps/expo/src/utils/voting.test.ts
new file mode 100644
index 00000000..030e2285
--- /dev/null
+++ b/apps/expo/src/utils/voting.test.ts
@@ -0,0 +1,377 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import type { PollingLocation, VoterInfoResponse } from "@acme/api";
+
+import type { VotingMethod, VotingMethodId, VotingPlan } from "./voting";
+import {
+ buildVotingPlan,
+ electionPhase,
+ entryCardSubtitle,
+ formatCivicAddress,
+ registrationCheckUrl,
+ resolveOfficialSource,
+ shortAddress,
+} from "./voting";
+
+/** ISO date `days` from now — every case here is relative to "today". */
+function offsetDays(days: number): string {
+ const d = new Date();
+ d.setDate(d.getDate() + days);
+ return d.toISOString().slice(0, 10);
+}
+
+function location(overrides: Partial = {}): PollingLocation {
+ return {
+ address: {
+ line1: "828 I Street",
+ city: "Sacramento",
+ state: "CA",
+ zip: "95814",
+ },
+ ...overrides,
+ };
+}
+
+function response(
+ overrides: Partial = {},
+): VoterInfoResponse {
+ return {
+ kind: "civicinfo#voterInfoResponse",
+ election: {
+ id: "1",
+ name: "California Statewide General Election",
+ electionDay: offsetDays(12),
+ ocdDivisionId: "ocd-division/country:us/state:ca",
+ },
+ normalizedInput: {
+ line1: "1414 K Street",
+ city: "Sacramento",
+ state: "CA",
+ zip: "95814",
+ },
+ ...overrides,
+ };
+}
+
+/** Fetch a method by id, failing the test rather than yielding `undefined`. */
+function method(plan: VotingPlan, id: VotingMethodId): VotingMethod {
+ const found = plan.methods.find((m) => m.id === id);
+ assert.ok(found, `expected a "${id}" method in the plan`);
+ return found;
+}
+
+void test("electionPhase reports the phase relative to today", () => {
+ assert.equal(electionPhase(offsetDays(12)), "upcoming");
+ assert.equal(electionPhase(offsetDays(0)), "electionDay");
+ assert.equal(electionPhase(offsetDays(-3)), "ended");
+ assert.equal(electionPhase(undefined), "upcoming");
+});
+
+void test("formatCivicAddress joins a Civic address onto one line", () => {
+ assert.equal(
+ formatCivicAddress({
+ line1: "828 I Street",
+ city: "Sacramento",
+ state: "CA",
+ zip: "95814",
+ }),
+ "828 I Street, Sacramento, CA 95814",
+ );
+});
+
+void test("shortAddress truncates the stored address to street and city", () => {
+ assert.equal(
+ shortAddress("1414 K Street, Sacramento, CA 95814, USA"),
+ "1414 K Street, Sacramento",
+ );
+ assert.equal(shortAddress("Sacramento, CA"), "Sacramento, CA");
+});
+
+void test("buildVotingPlan returns a full method list with no data at all", () => {
+ const plan = buildVotingPlan(undefined);
+ assert.deepEqual(
+ plan.methods.map((m) => m.id),
+ ["mail", "dropBox", "earlyInPerson", "electionDay"],
+ );
+ assert.equal(plan.noLocationsPublished, true);
+});
+
+void test("missing locations read as unpublished, never as unavailable", () => {
+ // The distinction is the whole point of the screen: "we don't know yet" must
+ // not render as "this method isn't offered".
+ const dropBox = method(buildVotingPlan(response()), "dropBox");
+ assert.equal(dropBox.status, "unknown");
+ assert.equal(dropBox.chip.label, "Not published");
+ assert.equal(dropBox.subtitle, "Locations not published yet");
+});
+
+void test("a method becomes available once locations are published", () => {
+ const plan = buildVotingPlan(
+ response({ dropOffLocations: [location(), location()] }),
+ );
+ const dropBox = method(plan, "dropBox");
+ assert.equal(dropBox.status, "available");
+ assert.equal(dropBox.chip.label, "Open now");
+ assert.equal(dropBox.subtitle, "2 locations");
+ assert.equal(plan.noLocationsPublished, false);
+});
+
+void test("location counts use singular phrasing for one location", () => {
+ const plan = buildVotingPlan(response({ pollingLocations: [location()] }));
+ assert.equal(method(plan, "electionDay").subtitle, "1 polling place");
+});
+
+void test("early voting is upcoming until its published window opens", () => {
+ const plan = buildVotingPlan(
+ response({ earlyVoteSites: [location({ startDate: offsetDays(2) })] }),
+ );
+ const early = method(plan, "earlyInPerson");
+ assert.equal(early.status, "upcoming");
+ assert.equal(early.chip.label, "Opens in 2 days");
+});
+
+void test("a method closes once its published window has passed", () => {
+ const plan = buildVotingPlan(
+ response({
+ earlyVoteSites: [
+ location({ startDate: offsetDays(-9), endDate: offsetDays(-2) }),
+ ],
+ }),
+ );
+ assert.equal(method(plan, "earlyInPerson").status, "closed");
+});
+
+void test("a mail-only election drops early voting and limits in-person", () => {
+ const plan = buildVotingPlan(response({ mailOnly: true }));
+ assert.equal(plan.mailOnly, true);
+ assert.equal(
+ plan.methods.some((m) => m.id === "earlyInPerson"),
+ false,
+ );
+ // In-person is reduced, not removed — mailOnly does not mean "you cannot vote
+ // in person", and hiding the row would say exactly that.
+ assert.equal(method(plan, "electionDay").status, "limited");
+ assert.equal(method(plan, "mail").status, "available");
+});
+
+void test("no step or subtitle ever states a deadline we cannot source", () => {
+ const plan = buildVotingPlan(response());
+ assert.equal(method(plan, "mail").subtitle, "Return deadline not available");
+ for (const m of plan.methods) {
+ for (const step of m.steps) {
+ assert.doesNotMatch(
+ `${step.title} ${step.detail ?? ""}`,
+ /\b(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\b/,
+ `step "${step.title}" must not name a date`,
+ );
+ }
+ }
+});
+
+void test("California postage guidance is gated on the resolved state", () => {
+ // Steps only exist alongside a citable source, so both halves need one.
+ const caMail = method(buildVotingPlan(withSource()), "mail");
+ assert.match(caMail.steps.at(-1)?.title ?? "", /no stamp needed/);
+
+ const nvPlan = buildVotingPlan(
+ withSource({
+ normalizedInput: {
+ line1: "1 Main St",
+ city: "Reno",
+ state: "NV",
+ zip: "89501",
+ },
+ }),
+ );
+ assert.doesNotMatch(
+ method(nvPlan, "mail").steps.at(-1)?.title ?? "",
+ /no stamp needed/,
+ );
+});
+
+void test("availableCount counts only methods usable today", () => {
+ const plan = buildVotingPlan(
+ response({
+ dropOffLocations: [location()],
+ pollingLocations: [location()],
+ earlyVoteSites: [location({ startDate: offsetDays(3) })],
+ }),
+ );
+ assert.equal(plan.availableCount, 2);
+});
+
+void test("resolveOfficialSource prefers the local jurisdiction", () => {
+ const source = resolveOfficialSource(
+ response({
+ state: [
+ {
+ name: "California",
+ electionAdministrationBody: {
+ name: "California Secretary of State",
+ electionInfoUrl: "https://sos.ca.gov",
+ },
+ localJurisdiction: {
+ name: "Sacramento County",
+ electionAdministrationBody: {
+ name: "Sacramento County Voter Registration & Elections",
+ electionInfoUrl: "https://elections.saccounty.gov",
+ electionOfficials: [{ officePhoneNumber: "(916) 875-6451" }],
+ },
+ },
+ },
+ ],
+ }),
+ );
+ assert.ok(source);
+ assert.equal(source.name, "Sacramento County Voter Registration & Elections");
+ assert.equal(source.electionInfoUrl, "https://elections.saccounty.gov");
+ assert.equal(source.phone, "(916) 875-6451");
+});
+
+void test("resolveOfficialSource falls back to the state body", () => {
+ const source = resolveOfficialSource(
+ response({
+ state: [
+ {
+ name: "California",
+ electionAdministrationBody: { name: "California Secretary of State" },
+ },
+ ],
+ }),
+ );
+ assert.ok(source);
+ assert.equal(source.name, "California Secretary of State");
+});
+
+void test("resolveOfficialSource yields nothing without administration data", () => {
+ assert.equal(resolveOfficialSource(response()), undefined);
+ assert.equal(resolveOfficialSource(undefined), undefined);
+});
+
+void test("entryCardSubtitle asks for an address before anything else", () => {
+ assert.equal(
+ entryCardSubtitle(false, undefined, "upcoming"),
+ "Add your address to see your options",
+ );
+});
+
+void test("entryCardSubtitle counts available ways to vote", () => {
+ const many = buildVotingPlan(
+ response({
+ dropOffLocations: [location()],
+ pollingLocations: [location()],
+ }),
+ );
+ assert.equal(
+ entryCardSubtitle(true, many, "upcoming"),
+ "2 ways to vote in this election",
+ );
+
+ const one = buildVotingPlan(response({ dropOffLocations: [location()] }));
+ assert.equal(
+ entryCardSubtitle(true, one, "upcoming"),
+ "1 way to vote in this election",
+ );
+});
+
+void test("entryCardSubtitle reframes on Election Day and after", () => {
+ const plan = buildVotingPlan(response({ pollingLocations: [location()] }));
+ assert.equal(
+ entryCardSubtitle(true, plan, "electionDay"),
+ "Polling places, hours, and directions",
+ );
+ assert.equal(
+ entryCardSubtitle(true, plan, "ended"),
+ "See results and what comes next",
+ );
+});
+
+void test("entryCardSubtitle never promises a deadline it doesn't have", () => {
+ assert.doesNotMatch(
+ entryCardSubtitle(true, buildVotingPlan(response()), "upcoming"),
+ /postmark|deadline by|due/i,
+ );
+});
+
+// --- source gating: no source, no step summary -----------------------------
+
+/** A response carrying an administration body, i.e. a citable source. */
+function withSource(
+ overrides: Partial = {},
+): VoterInfoResponse {
+ return response({
+ state: [
+ {
+ name: "California",
+ localJurisdiction: {
+ name: "Sacramento County",
+ electionAdministrationBody: {
+ name: "Sacramento County Voter Registration & Elections",
+ electionInfoUrl: "https://elections.saccounty.gov",
+ absenteeVotingInfoUrl: "https://elections.saccounty.gov/vbm",
+ votingLocationFinderUrl: "https://elections.saccounty.gov/centers",
+ },
+ },
+ },
+ ],
+ ...overrides,
+ });
+}
+
+void test("steps are withheld entirely when no source can be cited", () => {
+ // The steps summarize an authority's instructions. With nothing to point at,
+ // showing them would make Billion the author of voting procedure.
+ const plan = buildVotingPlan(response());
+ for (const m of plan.methods) {
+ assert.equal(m.steps.length, 0, `${m.id} must not carry unsourced steps`);
+ assert.equal(m.instructionsUrl, undefined);
+ }
+});
+
+void test("steps appear once an official instructions URL exists", () => {
+ const plan = buildVotingPlan(withSource());
+ const mail = method(plan, "mail");
+ assert.ok(mail.steps.length > 0);
+ assert.equal(mail.instructionsUrl, "https://elections.saccounty.gov/vbm");
+
+ const day = method(plan, "electionDay");
+ assert.ok(day.steps.length > 0);
+ assert.equal(day.instructionsUrl, "https://elections.saccounty.gov/centers");
+});
+
+void test("every method with steps can name the page it summarizes", () => {
+ const plan = buildVotingPlan(withSource());
+ for (const m of plan.methods) {
+ if (m.steps.length > 0) {
+ assert.ok(m.instructionsUrl, `${m.id} has steps but no source URL`);
+ }
+ }
+});
+
+// --- registration check always resolves ------------------------------------
+
+void test("registrationCheckUrl prefers the most specific official tool", () => {
+ assert.equal(
+ registrationCheckUrl({
+ name: "x",
+ registrationConfirmationUrl: "https://voterstatus.sos.ca.gov",
+ registrationUrl: "https://registertovote.ca.gov",
+ }),
+ "https://voterstatus.sos.ca.gov",
+ );
+ assert.equal(
+ registrationCheckUrl({
+ name: "x",
+ registrationUrl: "https://registertovote.ca.gov",
+ }),
+ "https://registertovote.ca.gov",
+ );
+});
+
+void test("registrationCheckUrl never leaves the reader without an exit", () => {
+ // The old build rendered "we can't confirm you're registered" with no action
+ // whenever Civic omitted both URLs. There must always be somewhere to go.
+ assert.equal(registrationCheckUrl(undefined), "https://vote.gov");
+ assert.equal(registrationCheckUrl({ name: "x" }), "https://vote.gov");
+});
diff --git a/apps/expo/src/utils/voting.ts b/apps/expo/src/utils/voting.ts
new file mode 100644
index 00000000..dbf012aa
--- /dev/null
+++ b/apps/expo/src/utils/voting.ts
@@ -0,0 +1,517 @@
+/**
+ * Voting-logistics derivation — the "how do I cast my ballot" model behind the
+ * How to Vote screen.
+ *
+ * Everything here is derived from data Google Civic actually returned. Nothing
+ * is inferred from the election date: this module deliberately has no
+ * "registration closes 15 days before" style arithmetic, because a deadline we
+ * computed is not a deadline any authority published. Where we don't have a
+ * fact, the model says so (`status: "unknown"`) and the UI renders an honest
+ * "not published" variant instead of a guess.
+ *
+ * See also `~/utils/elections` for ballot-content classification. This module
+ * is strictly logistics.
+ */
+
+import type {
+ AdministrationBody,
+ Address as CivicAddress,
+ PollingLocation,
+ VoterInfoResponse,
+} from "@acme/api";
+
+import { daysUntil } from "./dates";
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+export type VotingMethodId =
+ | "mail"
+ | "dropBox"
+ | "earlyInPerson"
+ | "electionDay";
+
+/**
+ * How usable a method is right now.
+ *
+ * `unknown` is load-bearing and must never collapse into `unavailable`: the
+ * former means "the county hasn't published this yet", the latter means "an
+ * official source says this isn't offered". Telling a voter a method doesn't
+ * exist when we simply don't know is the exact failure this screen exists to
+ * avoid.
+ */
+export type MethodStatus =
+ | "available" // usable today
+ | "upcoming" // published start date is in the future
+ | "closed" // published end date has passed
+ | "unknown" // offered, but the county hasn't published the details
+ | "limited"; // offered in a reduced form (e.g. in person during a mail-only election)
+
+export interface MethodChip {
+ label: string;
+ /** Which semantic colour the chip takes. Never the only signal — see label. */
+ tone: "positive" | "urgent" | "neutral" | "negative";
+ /** `IconName` from the shared icon set. */
+ icon: "check" | "clock" | "calendar" | "info" | "block";
+}
+
+export interface VotingMethod {
+ id: VotingMethodId;
+ title: string;
+ status: MethodStatus;
+ chip: MethodChip;
+ /** One-line status under the title. Empty string when we have nothing to say. */
+ subtitle: string;
+ /** Locations this method applies to, if any were published. */
+ locations: PollingLocation[];
+ /**
+ * Numbered checklist — a summary of the authority's own instructions, never
+ * Billion's independent advice. Empty unless `instructionsUrl` is set: if we
+ * can't point at the source we summarized, we don't show a summary.
+ */
+ steps: VotingStep[];
+ /** The authority page these steps summarize. Gates `steps` entirely. */
+ instructionsUrl?: string;
+ /** Published window for the method, when the feed carried one. */
+ startDate?: string;
+ endDate?: string;
+}
+
+export interface VotingStep {
+ /** The instruction. Must stand alone — a reader who skips details is still correct. */
+ title: string;
+ /** Consequence or caveat only. Omitted when we'd be padding. */
+ detail?: string;
+}
+
+export interface OfficialSource {
+ /** Verbatim authority name, e.g. "Sacramento County Voter Registration & Elections". */
+ name: string;
+ electionInfoUrl?: string;
+ registrationUrl?: string;
+ registrationConfirmationUrl?: string;
+ absenteeVotingInfoUrl?: string;
+ votingLocationFinderUrl?: string;
+ electionRulesUrl?: string;
+ /** First official phone number we can offer as an Election Day fallback. */
+ phone?: string;
+}
+
+/**
+ * Where to send someone who doesn't know whether they're registered.
+ *
+ * Always resolves: county/state tool if the feed carried one, otherwise the
+ * federal portal. A registration prompt with no action is worse than no
+ * prompt at all, so this never returns undefined.
+ */
+export function registrationCheckUrl(source?: OfficialSource): string {
+ return (
+ source?.registrationConfirmationUrl ??
+ source?.registrationUrl ??
+ "https://vote.gov"
+ );
+}
+
+export interface VotingPlan {
+ methods: VotingMethod[];
+ /** Methods a voter can act on today — drives the entry-point sublabel. */
+ availableCount: number;
+ mailOnly: boolean;
+ /** True when no method carried a single published location. */
+ noLocationsPublished: boolean;
+ /** The most specific election authority we could resolve, if any. */
+ source?: OfficialSource;
+}
+
+// ---------------------------------------------------------------------------
+// Election-day phase
+// ---------------------------------------------------------------------------
+
+export type ElectionPhase = "upcoming" | "electionDay" | "ended";
+
+/** Which phase of the election cycle a date falls in, relative to now. */
+export function electionPhase(electionDay: string | undefined): ElectionPhase {
+ if (!electionDay) return "upcoming";
+ const days = daysUntil(electionDay);
+ if (days === 0) return "electionDay";
+ return days < 0 ? "ended" : "upcoming";
+}
+
+// ---------------------------------------------------------------------------
+// Address formatting
+// ---------------------------------------------------------------------------
+
+/** One-line "100 Oak St, San Jose, CA 95112" from a Civic address. */
+export function formatCivicAddress(a: CivicAddress): string {
+ return [a.line1, a.line2, a.line3, `${a.city}, ${a.state} ${a.zip}`.trim()]
+ .filter(Boolean)
+ .join(", ");
+}
+
+/**
+ * Street + city only. The registered address is sensitive, and the How to Vote
+ * screen has no reason to render a ZIP back at someone who typed it.
+ */
+export function shortAddress(address: string): string {
+ const parts = address.split(",").map((p) => p.trim());
+ if (parts.length <= 2) return address;
+ return `${parts[0]}, ${parts[1]}`;
+}
+
+// ---------------------------------------------------------------------------
+// Method construction
+// ---------------------------------------------------------------------------
+
+const CHIP: Record = {
+ available: { label: "Available", tone: "positive", icon: "check" },
+ upcoming: { label: "Not open yet", tone: "urgent", icon: "clock" },
+ closed: { label: "Closed", tone: "negative", icon: "block" },
+ unknown: { label: "Not published", tone: "neutral", icon: "clock" },
+ limited: { label: "Limited", tone: "neutral", icon: "info" },
+};
+
+/** Earliest published `startDate` across a set of locations. */
+function earliestStart(locations: PollingLocation[]): string | undefined {
+ const dates = locations
+ .map((l) => l.startDate)
+ .filter((d): d is string => !!d)
+ .sort();
+ return dates[0];
+}
+
+/** Latest published `endDate` across a set of locations. */
+function latestEnd(locations: PollingLocation[]): string | undefined {
+ const dates = locations
+ .map((l) => l.endDate)
+ .filter((d): d is string => !!d)
+ .sort();
+ return dates[dates.length - 1];
+}
+
+/**
+ * Resolve a method's status from its published window.
+ *
+ * With no locations at all we return `unknown` rather than `unavailable` —
+ * Google Civic routinely returns an empty array weeks out, which is a
+ * publication gap and not a statement that the method isn't offered.
+ */
+function windowStatus(
+ locations: PollingLocation[],
+ start: string | undefined,
+ end: string | undefined,
+): MethodStatus {
+ if (locations.length === 0) return "unknown";
+ if (start && daysUntil(start) > 0) return "upcoming";
+ if (end && daysUntil(end) < 0) return "closed";
+ return "available";
+}
+
+/** "18 vote centers" / "1 vote center" — count phrasing shared by in-person rows. */
+function countLabel(n: number, singular: string, plural: string): string {
+ return `${n} ${n === 1 ? singular : plural}`;
+}
+
+/**
+ * Vote by mail.
+ *
+ * Steps are unconditional (they describe handling a ballot, not a jurisdiction
+ * rule) except the postage line, which is California-specific and therefore
+ * gated on the resolved state.
+ */
+function mailMethod(
+ resp: VoterInfoResponse | undefined,
+ isCalifornia: boolean,
+ source: OfficialSource | undefined,
+): VotingMethod {
+ const instructionsUrl =
+ source?.absenteeVotingInfoUrl ?? source?.electionInfoUrl;
+ const mailOnly = resp?.mailOnly === true;
+ const steps: VotingStep[] = [
+ {
+ title: "Find the ballot mailed to you",
+ detail: "Contact your county if it hasn't arrived.",
+ },
+ {
+ title: "Mark your choices in ink",
+ detail: "Skipping contests won't void your ballot.",
+ },
+ {
+ title: "Seal it in the official return envelope",
+ detail: "Any other envelope may not be counted.",
+ },
+ {
+ title: "Sign the back — it must match your registration",
+ detail: "The most common reason a ballot is rejected.",
+ },
+ isCalifornia
+ ? {
+ title: "Mail it — no stamp needed",
+ detail: "Postage is prepaid in California.",
+ }
+ : { title: "Mail it back as early as you can" },
+ ];
+
+ return {
+ id: "mail",
+ instructionsUrl,
+ title: mailOnly ? "Return your ballot by mail" : "Vote by mail",
+ // Every registered voter in an all-mail election is sent a ballot; outside
+ // one we can't confirm this voter gets one without a source, so the status
+ // stays honest rather than optimistic.
+ status: mailOnly ? "available" : "unknown",
+ chip: mailOnly ? CHIP.available : CHIP.unknown,
+ subtitle: mailOnly
+ ? "Every registered voter is mailed a ballot"
+ : "Return deadline not available",
+ locations: [],
+ steps: instructionsUrl ? steps : [],
+ };
+}
+
+/** Ballot drop boxes. */
+function dropBoxMethod(
+ locations: PollingLocation[],
+ source: OfficialSource | undefined,
+): VotingMethod {
+ const instructionsUrl =
+ source?.absenteeVotingInfoUrl ?? source?.electionInfoUrl;
+ const start = earliestStart(locations);
+ const end = latestEnd(locations);
+ const status = windowStatus(locations, start, end);
+
+ return {
+ id: "dropBox",
+ instructionsUrl,
+ title: "Return at a drop box",
+ status,
+ chip:
+ status === "available"
+ ? { ...CHIP.available, label: "Open now" }
+ : CHIP[status],
+ subtitle:
+ locations.length > 0
+ ? countLabel(locations.length, "location", "locations")
+ : "Locations not published yet",
+ locations,
+ startDate: start,
+ endDate: end,
+ steps: !instructionsUrl
+ ? []
+ : [
+ {
+ title: "Mark, seal, and sign your ballot first",
+ detail: "A drop box takes the same sealed return envelope.",
+ },
+ { title: "Drop it in any box in your county" },
+ {
+ title: "Arrive before your county's cutoff on Election Day",
+ detail: "Boxes are locked at closing; later isn't counted.",
+ },
+ ],
+ };
+}
+
+/** Early in-person voting / vote centers open before Election Day. */
+function earlyMethod(
+ locations: PollingLocation[],
+ source: OfficialSource | undefined,
+): VotingMethod {
+ const instructionsUrl =
+ source?.votingLocationFinderUrl ?? source?.electionInfoUrl;
+ const start = earliestStart(locations);
+ const end = latestEnd(locations);
+ const status = windowStatus(locations, start, end);
+
+ let chip = CHIP[status];
+ if (status === "upcoming" && start) {
+ const days = daysUntil(start);
+ chip = {
+ ...CHIP.upcoming,
+ label: days === 1 ? "Opens tomorrow" : `Opens in ${days} days`,
+ };
+ }
+
+ return {
+ id: "earlyInPerson",
+ instructionsUrl,
+ title: "Vote early in person",
+ status,
+ chip,
+ subtitle:
+ locations.length > 0
+ ? countLabel(locations.length, "early vote site", "early vote sites")
+ : "Locations not published yet",
+ locations,
+ startDate: start,
+ endDate: end,
+ steps: !instructionsUrl
+ ? []
+ : [
+ { title: "Go to any early vote site in your county" },
+ {
+ title: "Bring your mailed ballot if you have it",
+ detail: "You can surrender it and vote in person instead.",
+ },
+ { title: "Check the site's hours before you go" },
+ ],
+ };
+}
+
+/** Election Day polling places / vote centers. */
+function electionDayMethod(
+ locations: PollingLocation[],
+ mailOnly: boolean,
+ source: OfficialSource | undefined,
+): VotingMethod {
+ const instructionsUrl =
+ source?.votingLocationFinderUrl ?? source?.electionInfoUrl;
+ // In an all-mail election in-person service still exists — usually one
+ // office for replacement ballots and assistance. Dropping the row entirely
+ // would read as "you cannot vote in person", which isn't what mailOnly means.
+ if (mailOnly) {
+ return {
+ id: "electionDay",
+ instructionsUrl,
+ title: "Vote in person",
+ status: "limited",
+ chip: CHIP.limited,
+ subtitle: "In-person help is available for replacement ballots",
+ locations,
+ steps: !instructionsUrl
+ ? []
+ : [
+ { title: "Contact your county election office" },
+ {
+ title: "Ask about in-person service for this election",
+ detail: "All-mail elections still staff at least one location.",
+ },
+ ],
+ };
+ }
+
+ const status: MethodStatus = locations.length > 0 ? "available" : "unknown";
+ return {
+ id: "electionDay",
+ instructionsUrl,
+ title: "Vote in person on Election Day",
+ status,
+ chip: status === "available" ? CHIP.available : CHIP.unknown,
+ subtitle:
+ locations.length > 0
+ ? countLabel(locations.length, "polling place", "polling places")
+ : "Locations not published yet",
+ locations,
+ steps: !instructionsUrl
+ ? []
+ : [
+ { title: "Go to a polling place listed below" },
+ {
+ title: "Bring your mailed ballot if you received one",
+ detail: "You can surrender it and vote in person instead.",
+ },
+ {
+ title: "If you're in line when polls close, stay in line",
+ detail: "Anyone already in line is entitled to vote.",
+ },
+ ],
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Official source
+// ---------------------------------------------------------------------------
+
+/**
+ * Pick the most specific election authority in the response.
+ *
+ * Civic nests `localJurisdiction` inside `state`; the local body is the one
+ * that actually runs the election, so it wins when it names itself.
+ */
+export function resolveOfficialSource(
+ resp: VoterInfoResponse | undefined,
+): OfficialSource | undefined {
+ const region = resp?.state?.[0];
+ if (!region) return undefined;
+
+ const local = region.localJurisdiction?.electionAdministrationBody;
+ const state = region.electionAdministrationBody;
+ const body: AdministrationBody | undefined = local?.name ? local : state;
+ const name = body?.name ?? region.localJurisdiction?.name ?? region.name;
+ if (!name) return undefined;
+
+ const official = body?.electionOfficials?.find((o) => o.officePhoneNumber);
+
+ return {
+ name,
+ electionInfoUrl: body?.electionInfoUrl,
+ registrationUrl: body?.electionRegistrationUrl,
+ registrationConfirmationUrl: body?.electionRegistrationConfirmationUrl,
+ absenteeVotingInfoUrl: body?.absenteeVotingInfoUrl,
+ votingLocationFinderUrl: body?.votingLocationFinderUrl,
+ electionRulesUrl: body?.electionRulesUrl,
+ phone: official?.officePhoneNumber,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Entry point
+// ---------------------------------------------------------------------------
+
+/**
+ * Build the full voting plan for a voter-info response.
+ *
+ * Method order is fixed and independent of availability so the list doesn't
+ * reshuffle between visits — an unavailable method keeps its slot and explains
+ * itself rather than disappearing.
+ */
+export function buildVotingPlan(
+ resp: VoterInfoResponse | undefined,
+): VotingPlan {
+ const mailOnly = resp?.mailOnly === true;
+ const isCalifornia = resp?.normalizedInput.state === "CA";
+ const dropOff = resp?.dropOffLocations ?? [];
+ const early = resp?.earlyVoteSites ?? [];
+ const polling = resp?.pollingLocations ?? [];
+
+ // Resolved first: it gates whether any method may show a step summary.
+ const source = resolveOfficialSource(resp);
+
+ const methods: VotingMethod[] = [
+ mailMethod(resp, isCalifornia, source),
+ dropBoxMethod(dropOff, source),
+ ...(mailOnly ? [] : [earlyMethod(early, source)]),
+ electionDayMethod(polling, mailOnly, source),
+ ];
+
+ return {
+ methods,
+ availableCount: methods.filter((m) => m.status === "available").length,
+ mailOnly,
+ noLocationsPublished:
+ dropOff.length === 0 && early.length === 0 && polling.length === 0,
+ source,
+ };
+}
+
+/**
+ * Sublabel for the Elections-tab entry card.
+ *
+ * Deliberately never states a deadline: we have no sourced deadline data, and
+ * the entry point is the last place to start guessing at one.
+ */
+export function entryCardSubtitle(
+ hasAddress: boolean,
+ plan: VotingPlan | undefined,
+ phase: ElectionPhase,
+): string {
+ if (!hasAddress) return "Add your address to see your options";
+ if (phase === "ended") return "See results and what comes next";
+ if (!plan || plan.methods.length === 0) {
+ return "Deadlines and official election contacts";
+ }
+ if (phase === "electionDay") return "Polling places, hours, and directions";
+ const n = plan.availableCount;
+ if (n === 0) return "Ways to vote and where to go";
+ return `${countLabel(n, "way", "ways")} to vote in this election`;
+}
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
index 0d729333..f585ced9 100644
--- a/packages/api/src/index.ts
+++ b/packages/api/src/index.ts
@@ -15,6 +15,9 @@ export type {
DivisionByAddressResponse,
CivicDivision,
PollingLocation,
+ AdministrationRegion,
+ AdministrationBody,
+ ElectionOfficial,
Contest,
Candidate,
Source,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8d0139c8..6717104e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -317,6 +317,9 @@ importers:
tailwindcss:
specifier: 'catalog:'
version: 4.2.2
+ tsx:
+ specifier: ^4.21.0
+ version: 4.21.0
typescript:
specifier: ~6.0.3
version: 6.0.3