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