diff --git a/apps/expo/eslint.config.mts b/apps/expo/eslint.config.mts
index 84c3e9dc..bd072661 100644
--- a/apps/expo/eslint.config.mts
+++ b/apps/expo/eslint.config.mts
@@ -9,4 +9,11 @@ export default defineConfig(
},
baseConfig,
reactConfig,
+ {
+ // node:test's it()/describe() return promises by design.
+ files: ["**/*.test.ts", "**/*.test.tsx"],
+ rules: {
+ "@typescript-eslint/no-floating-promises": "off",
+ },
+ },
);
diff --git a/apps/expo/package.json b/apps/expo/package.json
index fdae88ff..d7024dd4 100644
--- a/apps/expo/package.json
+++ b/apps/expo/package.json
@@ -13,7 +13,8 @@
"lint": "eslint --flag unstable_native_nodejs_ts_config",
"typecheck": "tsc --noEmit",
"build:ios": "expo prebuild --platform ios --clean",
- "build:android": "expo prebuild --platform android --clean"
+ "build:android": "expo prebuild --platform android --clean",
+ "test": "tsx --test \"src/**/*.test.ts\""
},
"dependencies": {
"@acme/ui": "workspace:*",
@@ -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..6f8bcfbe 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 { LocalDecisionsPreview } from "~/components/LocalDecisionsPreview";
import { RepsSection } from "~/components/RepsSection";
import { Text } from "~/components/Themed";
import { Card, Icon, Kicker, Segmented, TabScreen } from "~/components/ui";
@@ -287,6 +288,9 @@ export default function ElectionsScreen() {
)}
+ {/* local government decisions — what city hall is doing right now */}
+
+
{/* election hero — what election is happening, what it means */}
{selected && }
diff --git a/apps/expo/src/app/local-decision-detail.tsx b/apps/expo/src/app/local-decision-detail.tsx
new file mode 100644
index 00000000..681d8836
--- /dev/null
+++ b/apps/expo/src/app/local-decision-detail.tsx
@@ -0,0 +1,451 @@
+/**
+ * Detail screen for one local-government decision: canonical Matter info,
+ * the multi-meeting occurrence timeline, official documents, and honest
+ * participation guidance. Only API-supported fields are shown.
+ */
+import { useState } from "react";
+import {
+ ActivityIndicator,
+ ScrollView,
+ StyleSheet,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { useLocalSearchParams, useRouter } from "expo-router";
+import { useQuery } from "@tanstack/react-query";
+
+import type { DecisionDetail } from "~/utils/local-government";
+import { ExternalLink } from "~/components/ExternalLink";
+import {
+ DocumentsSection,
+ ParticipationCard,
+} from "~/components/local-government/DocumentsSection";
+import { LifecycleChip } from "~/components/local-government/LifecycleChip";
+import { OccurrenceTimeline } from "~/components/local-government/OccurrenceTimeline";
+import { Text, View as ThemedView } from "~/components/Themed";
+import { Icon } from "~/components/ui/Icon";
+import { NavHeader } from "~/components/ui/NavHeader";
+import { colors, fontBody, fontDisplay, useTheme } from "~/styles";
+import { trpc } from "~/utils/api";
+import {
+ classifyDecision,
+ formatMeetingDate,
+ latestOccurrence,
+ lifecycleLabel,
+ nextUpcomingOccurrence,
+ parseDate,
+ scopeInfo,
+ topicLabel,
+} from "~/utils/local-government";
+
+export default function LocalDecisionDetailScreen() {
+ const router = useRouter();
+ const { theme } = useTheme();
+ const params = useLocalSearchParams<{ id?: string }>();
+ const id = typeof params.id === "string" ? params.id : undefined;
+
+ const query = useQuery({
+ ...trpc.legistar.getDecision.queryOptions({ id: id ?? "" }),
+ enabled: Boolean(id),
+ retry: false,
+ });
+
+ const errorCode = (query.error as { data?: { code?: string } } | null)?.data
+ ?.code;
+
+ if (!id || errorCode === "NOT_FOUND") {
+ return (
+
+ router.back()} />
+
+
+ );
+ }
+
+ if (query.isLoading) {
+ return (
+
+ router.back()} />
+
+
+
+
+ );
+ }
+
+ if (query.error || !query.data) {
+ return (
+
+ router.back()} />
+
+
+ );
+ }
+
+ return router.back()} />;
+}
+
+function DecisionBody({
+ decision,
+ onBack,
+}: {
+ decision: DecisionDetail;
+ onBack: () => void;
+}) {
+ const { theme } = useTheme();
+ const [historyOpen, setHistoryOpen] = useState(false);
+
+ const latest = latestOccurrence(decision.occurrences);
+ const lifecycle = classifyDecision({
+ status: decision.status,
+ type: decision.type,
+ outcome: latest?.action ?? null,
+ passed: null,
+ meetingCancelled: latest?.cancelled ?? false,
+ meetingStartsAt: latest?.startsAt ?? null,
+ });
+ const scope = scopeInfo(
+ decision.scope,
+ decision.districtNumbers,
+ decision.geographicText,
+ );
+ const topic = topicLabel(decision.topic);
+ const upcoming = nextUpcomingOccurrence(decision.occurrences);
+
+ return (
+
+
+
+ {/* Header */}
+ {topic ? (
+ {topic}
+ ) : null}
+
+ {decision.title}
+
+
+
+
+
+
+ {[
+ decision.jurisdiction,
+ scope.sentence ??
+ "Geographic area not specified in the official record.",
+ ].join(" · ")}
+
+
+ {/* Key facts */}
+
+
+ {upcoming ? (
+
+ ) : decision.occurrences[0] ? (
+
+ ) : (
+
+ )}
+ {decision.fileNumber ? (
+
+ ) : null}
+ {decision.type ? (
+
+ ) : null}
+ {parseDate(decision.introDate) ? (
+
+ ) : null}
+ {decision.enactmentNumber && parseDate(decision.enactmentDate) ? (
+
+ ) : null}
+
+
+ {/* Official source */}
+ {decision.sourceUrl ? (
+
+
+
+
+ Open on the official {decision.jurisdiction} site
+
+
+
+
+ ) : null}
+
+ {/* Timeline of meeting occurrences */}
+ {decision.occurrences.length > 0 ? (
+ <>
+
+ {decision.occurrences.length > 1
+ ? `Considered at ${decision.occurrences.length} meetings`
+ : "Meeting appearance"}
+
+
+ >
+ ) : (
+
+ This file exists in the official record, but no meeting agenda has
+ published it yet.
+
+ )}
+
+ {/* Published matter history (when Legistar provides structured history) */}
+ {decision.history.length > 0 ? (
+ <>
+ setHistoryOpen((open) => !open)}
+ style={[s.historyToggle, { borderColor: theme.border }]}
+ accessibilityRole="button"
+ accessibilityState={{ expanded: historyOpen }}
+ >
+
+ {historyOpen ? "Hide" : "Show"} published action history (
+ {decision.history.length})
+
+
+
+ {historyOpen && (
+
+ {decision.history.map((entry) => (
+
+
+ {entry.actionDate
+ ? formatMeetingDate(entry.actionDate)
+ : "Undated"}
+
+
+ {[entry.body, entry.action].filter(Boolean).join(" · ")}
+
+
+ ))}
+
+ )}
+ >
+ ) : null}
+
+
+
+
+
+ {/* Honest provenance footer */}
+
+ Everything above comes from {decision.jurisdiction}'s published
+ records, last updated {formatMeetingDate(decision.sourceUpdatedAt)}.
+ Blank fields mean the city hasn't published that information — not
+ that it doesn't exist.
+
+
+
+ );
+}
+
+function SectionHeading({ children }: { children: string }) {
+ const { theme } = useTheme();
+ return (
+
+ {children.toUpperCase()}
+
+ );
+}
+
+function FactRow({ label, value }: { label: string; value: string }) {
+ const { theme } = useTheme();
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+function DetailState({ title, body }: { title: string; body: string }) {
+ const { theme } = useTheme();
+ return (
+
+ {title}
+ {body}
+
+ );
+}
+
+const s = StyleSheet.create({
+ screen: { flex: 1 },
+ scroll: { flex: 1 },
+ scrollContent: {
+ paddingHorizontal: 20,
+ paddingBottom: 64,
+ },
+ center: {
+ flex: 1,
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 8,
+ paddingHorizontal: 32,
+ },
+ kicker: {
+ fontFamily: fontBody.semibold,
+ fontSize: 11,
+ textTransform: "uppercase",
+ letterSpacing: 1,
+ marginBottom: 6,
+ },
+ title: {
+ fontFamily: fontDisplay.bold,
+ fontSize: 24,
+ lineHeight: 30,
+ marginBottom: 12,
+ },
+ statusRow: { flexDirection: "row", marginBottom: 10 },
+ scopeSentence: {
+ fontFamily: fontBody.regular,
+ fontSize: 13.5,
+ lineHeight: 19,
+ marginBottom: 16,
+ },
+ factsCard: {
+ borderRadius: 12,
+ paddingVertical: 4,
+ paddingHorizontal: 12,
+ marginBottom: 12,
+ },
+ factRow: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ gap: 12,
+ paddingVertical: 9,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ },
+ factLabel: {
+ fontFamily: fontBody.medium,
+ fontSize: 12.5,
+ flexShrink: 0,
+ },
+ factValue: {
+ fontFamily: fontBody.medium,
+ fontSize: 12.5,
+ textAlign: "right",
+ flexShrink: 1,
+ },
+ sourceButton: {
+ flexDirection: "row",
+ alignItems: "center",
+ alignSelf: "flex-start",
+ gap: 7,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderRadius: 999,
+ paddingHorizontal: 12,
+ paddingVertical: 8,
+ marginBottom: 24,
+ },
+ sourceButtonText: {
+ fontFamily: fontBody.semibold,
+ fontSize: 12.5,
+ },
+ sectionHeading: {
+ fontFamily: fontBody.semibold,
+ fontSize: 11,
+ letterSpacing: 0.8,
+ marginTop: 8,
+ marginBottom: 12,
+ },
+ partialNote: {
+ fontFamily: fontBody.regular,
+ fontSize: 13,
+ lineHeight: 18,
+ marginVertical: 12,
+ },
+ historyToggle: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ borderWidth: StyleSheet.hairlineWidth,
+ borderRadius: 10,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ marginTop: 4,
+ },
+ historyToggleText: {
+ fontFamily: fontBody.semibold,
+ fontSize: 12.5,
+ },
+ historyCard: {
+ borderRadius: 10,
+ padding: 12,
+ marginTop: 6,
+ gap: 8,
+ },
+ historyRow: { gap: 2 },
+ historyDate: {
+ fontFamily: fontBody.semibold,
+ fontSize: 12.5,
+ },
+ historyAction: {
+ fontFamily: fontBody.regular,
+ fontSize: 12,
+ lineHeight: 17,
+ },
+ provenance: {
+ fontFamily: fontBody.regular,
+ fontSize: 12,
+ lineHeight: 17,
+ marginTop: 20,
+ },
+ stateTitle: {
+ fontFamily: fontDisplay.bold,
+ fontSize: 18,
+ textAlign: "center",
+ },
+ stateBody: {
+ fontFamily: fontBody.regular,
+ fontSize: 14,
+ lineHeight: 20,
+ textAlign: "center",
+ },
+});
diff --git a/apps/expo/src/app/local-decisions.tsx b/apps/expo/src/app/local-decisions.tsx
new file mode 100644
index 00000000..50b9234d
--- /dev/null
+++ b/apps/expo/src/app/local-decisions.tsx
@@ -0,0 +1,364 @@
+/**
+ * "What San Jose Is Deciding" - the local-government decision list.
+ *
+ * Jurisdiction-neutral: every place name comes from data or the user's saved
+ * address, never from this file. The first-release ingestion pipeline covers
+ * San Jose; other jurisdictions show honest empty states.
+ */
+import type { Href } from "expo-router";
+import { useMemo, useRef, useState } from "react";
+import {
+ ActivityIndicator,
+ FlatList,
+ RefreshControl,
+ Text as RNText,
+ StyleSheet,
+ TouchableOpacity,
+} from "react-native";
+import { useRouter } from "expo-router";
+import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
+
+import { DecisionCard } from "~/components/local-government/DecisionCard";
+import { DecisionListSkeleton } from "~/components/local-government/DecisionSkeletons";
+import { Text, View } from "~/components/Themed";
+import { Pill, Pills } from "~/components/ui";
+import { Icon } from "~/components/ui/Icon";
+import { NavHeader } from "~/components/ui/NavHeader";
+import { Segmented } from "~/components/ui/Segmented";
+import { useUserAddress } from "~/hooks/useUserAddress";
+import { colors, fontBody, fontDisplay, useTheme } from "~/styles";
+import { trpc } from "~/utils/api";
+import {
+ detectJurisdictionKey,
+ formatMeetingDate,
+ JURISDICTION_FALLBACK_NAMES,
+ topicLabelOrFallback,
+} from "~/utils/local-government";
+
+const PAGE_SIZE = 20;
+/** A sync older than this is flagged as stale in the header note. */
+const STALE_AFTER_MS = 3 * 24 * 60 * 60 * 1000;
+
+type TimelineTab = "upcoming" | "recent";
+
+export default function LocalDecisionsScreen() {
+ const router = useRouter();
+ const { theme } = useTheme();
+ const { address } = useUserAddress();
+
+ const jurisdiction = detectJurisdictionKey(address);
+ const jurisdictionName = JURISDICTION_FALLBACK_NAMES[jurisdiction];
+
+ const [tab, setTab] = useState("upcoming");
+ const [topic, setTopic] = useState(null);
+
+ const listInput = useMemo(
+ () => ({
+ jurisdiction,
+ timeline: tab,
+ topic: topic ?? undefined,
+ limit: PAGE_SIZE,
+ }),
+ [jurisdiction, tab, topic],
+ );
+
+ const {
+ data,
+ isLoading,
+ error,
+ refetch,
+ isRefetching,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ } = useInfiniteQuery(
+ trpc.legistar.listDecisions.infiniteQueryOptions(
+ { ...listInput, cursor: 0 },
+ {
+ initialCursor: 0,
+ getNextPageParam: (lastPage, allPages) =>
+ lastPage.length === PAGE_SIZE ? allPages.length * PAGE_SIZE : null,
+ },
+ ),
+ );
+
+ // One bounded probe so filter pills only appear for topics that actually
+ // have decisions right now.
+ const topicsProbe = useQuery(
+ trpc.legistar.listDecisions.queryOptions({
+ jurisdiction,
+ timeline: "all",
+ limit: 100,
+ }),
+ );
+ const availableTopics = useMemo(() => {
+ const seen = new Set();
+ for (const row of topicsProbe.data ?? []) {
+ if (row.topic) seen.add(row.topic);
+ }
+ return [...seen].sort();
+ }, [topicsProbe.data]);
+
+ const healthQuery = useQuery(
+ trpc.legistar.getIngestionHealth.queryOptions({ jurisdiction }),
+ );
+ const latestRun = healthQuery.data?.latestRun ?? null;
+ const syncFailed = latestRun?.status === "failed";
+ const lastSyncedAt = latestRun?.startedAt ?? null;
+ const syncIsStale =
+ syncFailed ||
+ (lastSyncedAt !== null &&
+ Date.now() - new Date(lastSyncedAt).getTime() > STALE_AFTER_MS);
+
+ const decisions = useMemo(() => data?.pages.flat() ?? [], [data]);
+
+ // One row per agenda appearance; the feed shows each decision once, at its
+ // most relevant upcoming (or latest recent) occurrence.
+ const uniqueDecisions = useMemo(() => {
+ const seen = new Set();
+ return decisions.filter((row) => {
+ if (seen.has(row.id)) return false;
+ seen.add(row.id);
+ return true;
+ });
+ }, [decisions]);
+
+ const refreshInFlight = useRef(false);
+ const handleRefresh = async () => {
+ if (refreshInFlight.current) return;
+ refreshInFlight.current = true;
+ try {
+ await Promise.all([
+ refetch({ throwOnError: false }),
+ healthQuery.refetch({ throwOnError: false }),
+ ]);
+ } finally {
+ refreshInFlight.current = false;
+ }
+ };
+
+ return (
+
+ router.back()}
+ />
+
+
+
+ options={[
+ { id: "upcoming", label: "Upcoming" },
+ { id: "recent", label: "Recently decided" },
+ ]}
+ value={tab}
+ onChange={(next) => {
+ setTopic(null);
+ setTab(next);
+ }}
+ />
+
+
+ {availableTopics.length > 0 && (
+
+
+ setTopic(null)}
+ />
+ {availableTopics.map((t) => (
+ setTopic(topic === t ? null : t)}
+ />
+ ))}
+
+
+ )}
+
+
+
+ row.id}
+ showsVerticalScrollIndicator={false}
+ refreshControl={
+ void handleRefresh()}
+ tintColor={colors.white}
+ />
+ }
+ renderItem={({ item }) => (
+
+ router.push(`/local-decision-detail?id=${item.id}` as Href)
+ }
+ />
+ )}
+ onEndReached={() => {
+ if (hasNextPage && !isFetchingNextPage) void fetchNextPage();
+ }}
+ onEndReachedThreshold={0.5}
+ ListFooterComponent={
+ isFetchingNextPage ? (
+
+ ) : null
+ }
+ ListEmptyComponent={
+ isLoading ? (
+
+
+
+ ) : error ? (
+ void refetch()}
+ />
+ ) : (
+ {
+ setTopic(null);
+ setTab(tab === "upcoming" ? "recent" : "upcoming");
+ }}
+ />
+ )
+ }
+ />
+
+ );
+}
+
+function SyncNote({
+ visible,
+ jurisdictionName,
+ syncFailed,
+ lastSyncedAt,
+}: {
+ visible: boolean;
+ jurisdictionName: string;
+ syncFailed: boolean;
+ lastSyncedAt: Date | string | null;
+}) {
+ const { theme } = useTheme();
+ if (!visible) return null;
+ return (
+
+
+
+ {syncFailed
+ ? `The last sync with ${jurisdictionName}'s official records failed, so this list may be out of date.`
+ : lastSyncedAt
+ ? `Official records were last synced ${formatMeetingDate(lastSyncedAt)}.`
+ : `Sync status for ${jurisdictionName} is unavailable.`}
+
+
+ );
+}
+
+function ListState({
+ title,
+ body,
+ actionLabel,
+ onAction,
+}: {
+ title: string;
+ body: string;
+ actionLabel?: string;
+ onAction?: () => void;
+}) {
+ const { theme } = useTheme();
+ return (
+
+ {title}
+ {body}
+ {actionLabel && onAction ? (
+
+
+ {actionLabel}
+
+
+ ) : null}
+
+ );
+}
+
+const s = StyleSheet.create({
+ screen: { flex: 1 },
+ controls: { paddingHorizontal: 20, paddingBottom: 10 },
+ list: { flex: 1 },
+ listContent: { paddingHorizontal: 20, paddingBottom: 48, gap: 12 },
+ footerSpinner: { marginVertical: 16 },
+ syncNote: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 8,
+ paddingHorizontal: 20,
+ paddingBottom: 8,
+ },
+ syncText: {
+ fontFamily: fontBody.medium,
+ fontSize: 12,
+ lineHeight: 17,
+ flex: 1,
+ },
+ center: {
+ alignItems: "center",
+ paddingHorizontal: 32,
+ paddingVertical: 56,
+ gap: 8,
+ },
+ emptyTitle: {
+ fontFamily: fontDisplay.bold,
+ fontSize: 18,
+ textAlign: "center",
+ lineHeight: 24,
+ },
+ emptyBody: {
+ fontFamily: fontBody.regular,
+ fontSize: 14,
+ lineHeight: 20,
+ textAlign: "center",
+ },
+ emptyAction: {
+ borderWidth: 1,
+ borderRadius: 999,
+ paddingHorizontal: 18,
+ paddingVertical: 10,
+ marginTop: 10,
+ },
+ emptyActionText: {
+ fontFamily: fontBody.semibold,
+ fontSize: 13,
+ },
+});
diff --git a/apps/expo/src/app/local-elections.tsx b/apps/expo/src/app/local-elections.tsx
index 52dfebe1..3b59d9c3 100644
--- a/apps/expo/src/app/local-elections.tsx
+++ b/apps/expo/src/app/local-elections.tsx
@@ -5,12 +5,11 @@ import { FontAwesome } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";
import { KeyDatesSection } from "~/components/KeyDatesSection";
-import { LocalBillsSection } from "~/components/LocalBillsSection";
+import { LocalDecisionsPreview } from "~/components/LocalDecisionsPreview";
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";
@@ -78,9 +77,7 @@ export default function LocalElectionsScreen() {
-
-
-
+
);
diff --git a/apps/expo/src/components/ExternalLink.tsx b/apps/expo/src/components/ExternalLink.tsx
index 012cd18c..613b57db 100644
--- a/apps/expo/src/components/ExternalLink.tsx
+++ b/apps/expo/src/components/ExternalLink.tsx
@@ -1,18 +1,20 @@
-import type { Href } from "expo-router";
import type React from "react";
import { Platform } from "react-native";
import { Link } from "expo-router";
import * as WebBrowser from "expo-web-browser";
-type HrefString = T extends string ? T : never;
export function ExternalLink(
- props: Omit, "href"> & { href: HrefString },
+ props: Omit, "href"> & {
+ /** Official source URLs arrive as runtime strings, not literal routes. */
+ href: string;
+ },
) {
+ const href = props.href as React.ComponentProps["href"];
return (
{
if (Platform.OS !== "web") {
// Prevent the default behavior of linking to the default browser on native.
diff --git a/apps/expo/src/components/LocalBillsSection.tsx b/apps/expo/src/components/LocalBillsSection.tsx
deleted file mode 100644
index 4cb9d709..00000000
--- a/apps/expo/src/components/LocalBillsSection.tsx
+++ /dev/null
@@ -1,131 +0,0 @@
-import { ActivityIndicator, StyleSheet, TouchableOpacity } from "react-native";
-import { FontAwesome } from "@expo/vector-icons";
-import { useQuery } from "@tanstack/react-query";
-
-import type { LegistarMatter } from "@acme/api/integrations/legistar";
-
-import { Text, View } from "~/components/Themed";
-import { fontBody, fontEditorial, fontSize, rd, sp, useTheme } from "~/styles";
-import { trpc } from "~/utils/api";
-
-interface LocalBillsSectionProps {
- onBillPress?: (bill: LegistarMatter) => void;
-}
-
-export function LocalBillsSection({ onBillPress }: LocalBillsSectionProps) {
- const { theme } = useTheme();
-
- const billsQuery = useQuery(trpc.legistar.getLocalBills.queryOptions());
-
- return (
-
- Local Bills
-
- {billsQuery.isLoading && (
-
- )}
-
- {billsQuery.data?.map((bill, index) => (
- onBillPress?.(bill as LegistarMatter)}
- activeOpacity={0.8}
- >
-
-
-
- {bill.jurisdiction}
- {bill.MatterStatusName}
-
-
- {bill.MatterTitle}
-
- {bill.MatterFile}
-
-
-
- ))}
-
- {billsQuery.data?.length === 0 && (
- No recent local legislation
- )}
-
- );
-}
-
-const colors = {
- white: "#FFFFFF",
- civicBlue: "#4A7CFF",
- textMuted: "#8A8FA0",
-};
-
-const styles = StyleSheet.create({
- container: {
- marginHorizontal: sp[4],
- marginBottom: sp[6],
- },
- sectionTitle: {
- fontFamily: fontEditorial.bold,
- fontSize: fontSize.lg,
- color: colors.white,
- marginBottom: sp[3],
- },
- loader: {
- marginVertical: sp[6],
- },
- card: {
- flexDirection: "row",
- alignItems: "center",
- borderRadius: rd.md,
- marginBottom: sp[3],
- overflow: "hidden",
- },
- cardAccent: {
- width: 4,
- alignSelf: "stretch",
- backgroundColor: colors.civicBlue,
- },
- cardContent: {
- flex: 1,
- padding: sp[4],
- },
- meta: {
- flexDirection: "row",
- gap: sp[3],
- marginBottom: sp[2],
- },
- jurisdiction: {
- fontFamily: fontBody.semibold,
- fontSize: 10,
- color: colors.civicBlue,
- textTransform: "uppercase",
- },
- status: {
- fontFamily: fontBody.regular,
- fontSize: 10,
- color: colors.textMuted,
- },
- title: {
- fontFamily: fontBody.medium,
- fontSize: fontSize.sm,
- color: colors.white,
- marginBottom: sp[2],
- },
- file: {
- fontFamily: fontBody.regular,
- fontSize: fontSize.xs,
- color: colors.textMuted,
- },
- noData: {
- fontFamily: fontBody.regular,
- fontSize: fontSize.sm,
- color: colors.textMuted,
- textAlign: "center",
- marginVertical: sp[6],
- },
-});
diff --git a/apps/expo/src/components/LocalDecisionsPreview.tsx b/apps/expo/src/components/LocalDecisionsPreview.tsx
new file mode 100644
index 00000000..123e561e
--- /dev/null
+++ b/apps/expo/src/components/LocalDecisionsPreview.tsx
@@ -0,0 +1,157 @@
+/**
+ * Compact local-government preview for existing surfaces. Links into the full
+ * decision experience — it never duplicates the list inline.
+ */
+import type { Href } from "expo-router";
+import {
+ Text as RNText,
+ StyleSheet,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { useRouter } from "expo-router";
+import { useQuery } from "@tanstack/react-query";
+
+import { Icon } from "~/components/ui/Icon";
+import { useUserAddress } from "~/hooks/useUserAddress";
+import { colors, fontBody, fontDisplay, hair, planes } from "~/styles";
+import { trpc } from "~/utils/api";
+import {
+ classifyDecision,
+ detectJurisdictionKey,
+ formatMeetingDate,
+ JURISDICTION_FALLBACK_NAMES,
+ lifecycleVisual,
+} from "~/utils/local-government";
+
+const PREVIEW_COUNT = 3;
+
+export function LocalDecisionsPreview() {
+ const router = useRouter();
+ const { address } = useUserAddress();
+
+ const jurisdiction = detectJurisdictionKey(address);
+ const jurisdictionName = JURISDICTION_FALLBACK_NAMES[jurisdiction];
+
+ const query = useQuery(
+ trpc.legistar.listDecisions.queryOptions({
+ jurisdiction,
+ timeline: "upcoming",
+ limit: PREVIEW_COUNT,
+ }),
+ );
+
+ const rows = query.data ?? [];
+
+ return (
+ router.push("/local-decisions" as Href)}
+ accessibilityRole="button"
+ accessibilityHint="Opens the full list of upcoming and recent local decisions"
+ style={[s.card, { backgroundColor: planes.slate, borderColor: hair[2] }]}
+ >
+
+
+ What {jurisdictionName} is deciding
+
+
+
+ Upcoming agendas and recently decided items
+
+
+ {query.isLoading ? (
+ <>
+
+
+ >
+ ) : rows.length === 0 ? (
+
+ No published meetings in the pipeline right now. Open the list for
+ details.
+
+ ) : (
+ rows.map((row) => {
+ const lifecycle = classifyDecision({
+ status: row.status,
+ type: row.type,
+ outcome: row.outcome,
+ passed: row.passed,
+ meetingCancelled: row.meetingCancelled,
+ meetingStartsAt: row.meetingStartsAt,
+ });
+ const visual = lifecycleVisual(lifecycle);
+ const tint =
+ visual.tint === "accent"
+ ? colors.bill
+ : visual.tint === "success"
+ ? colors.green[500]
+ : visual.tint === "warning"
+ ? colors.yellow[500]
+ : visual.tint === "danger"
+ ? colors.red[400]
+ : colors.textSecondary;
+ return (
+
+
+
+ {row.title}
+
+
+ {formatMeetingDate(row.meetingStartsAt)}
+
+
+ );
+ })
+ )}
+
+ );
+}
+
+const s = StyleSheet.create({
+ card: {
+ borderRadius: 14,
+ borderWidth: 1,
+ padding: 16,
+ marginHorizontal: 20,
+ marginBottom: 24,
+ },
+ head: { flexDirection: "row", alignItems: "center", gap: 9 },
+ title: {
+ flex: 1,
+ fontFamily: fontDisplay.bold,
+ fontSize: 17,
+ color: colors.white,
+ },
+ subtitle: {
+ fontFamily: fontBody.regular,
+ fontSize: 12.5,
+ color: colors.textSecondary,
+ marginTop: 3,
+ marginBottom: 10,
+ },
+ skeletonLine: { height: 14, borderRadius: 7, marginTop: 8 },
+ empty: {
+ fontFamily: fontBody.regular,
+ fontSize: 12.5,
+ color: colors.textSecondary,
+ lineHeight: 18,
+ },
+ row: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 8,
+ paddingVertical: 5,
+ },
+ rowTitle: {
+ flex: 1,
+ fontFamily: fontBody.medium,
+ fontSize: 12.5,
+ color: colors.white,
+ },
+ rowDate: {
+ fontFamily: fontBody.regular,
+ fontSize: 11,
+ color: colors.textSecondary,
+ },
+});
diff --git a/apps/expo/src/components/UpcomingMeetingsSection.tsx b/apps/expo/src/components/UpcomingMeetingsSection.tsx
deleted file mode 100644
index 93bbbf27..00000000
--- a/apps/expo/src/components/UpcomingMeetingsSection.tsx
+++ /dev/null
@@ -1,204 +0,0 @@
-import {
- ActivityIndicator,
- Linking,
- StyleSheet,
- TouchableOpacity,
-} from "react-native";
-import { FontAwesome } from "@expo/vector-icons";
-import { useQuery } from "@tanstack/react-query";
-
-import type { LegistarMeeting } from "@acme/api/integrations/legistar";
-
-import { Text, View } from "~/components/Themed";
-import { fontBody, fontEditorial, fontSize, rd, sp, useTheme } from "~/styles";
-import { trpc } from "~/utils/api";
-
-interface UpcomingMeetingsSectionProps {
- onMeetingPress?: (
- meeting: LegistarMeeting & { jurisdiction: string },
- ) => void;
-}
-
-function formatDate(iso: string): string {
- const d = new Date(iso);
- return d.toLocaleDateString("en-US", {
- weekday: "short",
- month: "short",
- day: "numeric",
- });
-}
-
-export function UpcomingMeetingsSection({
- onMeetingPress,
-}: UpcomingMeetingsSectionProps) {
- const { theme } = useTheme();
-
- const meetingsQuery = useQuery(
- trpc.legistar.getMeetings.queryOptions({ daysAhead: 30 }),
- );
-
- return (
-
- Upcoming Meetings
-
- {meetingsQuery.isLoading && (
-
- )}
-
- {meetingsQuery.data?.slice(0, 8).map((meeting, index) => (
- onMeetingPress?.(meeting)}
- activeOpacity={0.8}
- >
-
-
-
- {meeting.jurisdiction}
- {formatDate(meeting.EventDate)}
-
-
- {meeting.EventBodyName}
-
- {meeting.EventLocation && (
-
- {meeting.EventLocation}
-
- )}
-
- {meeting.EventAgendaFile && (
-
- void Linking.openURL(meeting.EventAgendaFile ?? "")
- }
- hitSlop={8}
- >
-
-
- )}
- {meeting.EventVideoPath && (
-
- void Linking.openURL(meeting.EventVideoPath ?? "")
- }
- hitSlop={8}
- >
-
-
- )}
- {meeting.EventMinutesFile && (
-
- void Linking.openURL(meeting.EventMinutesFile ?? "")
- }
- hitSlop={8}
- >
-
-
- )}
-
-
-
-
- ))}
-
- {meetingsQuery.data?.length === 0 && (
- No upcoming meetings
- )}
-
- );
-}
-
-const colors = {
- white: "#FFFFFF",
- civicBlue: "#4A7CFF",
- textMuted: "#8A8FA0",
-};
-
-const styles = StyleSheet.create({
- container: {
- marginHorizontal: sp[4],
- marginBottom: sp[6],
- },
- sectionTitle: {
- fontFamily: fontEditorial.bold,
- fontSize: fontSize.lg,
- color: colors.white,
- marginBottom: sp[3],
- },
- loader: {
- marginVertical: sp[6],
- },
- card: {
- flexDirection: "row",
- alignItems: "center",
- borderRadius: rd.md,
- marginBottom: sp[3],
- overflow: "hidden",
- },
- cardAccent: {
- width: 4,
- alignSelf: "stretch",
- backgroundColor: colors.civicBlue,
- },
- cardContent: {
- flex: 1,
- padding: sp[4],
- },
- meta: {
- flexDirection: "row",
- justifyContent: "space-between",
- marginBottom: sp[2],
- },
- jurisdiction: {
- fontFamily: fontBody.semibold,
- fontSize: 10,
- color: colors.civicBlue,
- textTransform: "uppercase",
- },
- date: {
- fontFamily: fontBody.medium,
- fontSize: 10,
- color: colors.civicBlue,
- },
- title: {
- fontFamily: fontBody.medium,
- fontSize: fontSize.sm,
- color: colors.white,
- marginBottom: sp[1],
- },
- location: {
- fontFamily: fontBody.regular,
- fontSize: fontSize.xs,
- color: colors.textMuted,
- marginBottom: sp[2],
- },
- icons: {
- flexDirection: "row",
- gap: sp[3],
- },
- noData: {
- fontFamily: fontBody.regular,
- fontSize: fontSize.sm,
- color: colors.textMuted,
- textAlign: "center",
- marginVertical: sp[6],
- },
-});
diff --git a/apps/expo/src/components/VotingRecordsSection.tsx b/apps/expo/src/components/VotingRecordsSection.tsx
deleted file mode 100644
index 5895cd7a..00000000
--- a/apps/expo/src/components/VotingRecordsSection.tsx
+++ /dev/null
@@ -1,254 +0,0 @@
-import { useState } from "react";
-import { ActivityIndicator, StyleSheet, TouchableOpacity } from "react-native";
-import { FontAwesome } from "@expo/vector-icons";
-import { useQuery } from "@tanstack/react-query";
-
-import type { Jurisdiction } from "@acme/api/integrations/legistar";
-
-import { Text, View } from "~/components/Themed";
-import { fontBody, fontEditorial, fontSize, rd, sp, useTheme } from "~/styles";
-import { trpc } from "~/utils/api";
-
-interface VotingRecordsSectionProps {
- jurisdiction: Jurisdiction;
- meetingId: number;
- meetingTitle?: string;
-}
-
-function PassFailBadge({ status }: { status: string | null }) {
- const isPass = status === "Pass";
- const isFail = status === "Fail";
- const bg = isPass ? "#1B3A2D" : isFail ? "#3A1B1B" : "#2A2A3A";
- const fg = isPass ? "#4ADE80" : isFail ? "#F87171" : "#8A8FA0";
- const label = status ?? "N/A";
-
- return (
-
- {label}
-
- );
-}
-
-const badgeStyles = StyleSheet.create({
- badge: {
- paddingHorizontal: 8,
- paddingVertical: 2,
- borderRadius: 4,
- },
- text: {
- fontFamily: fontBody.semibold,
- fontSize: 10,
- textTransform: "uppercase",
- },
-});
-
-export function VotingRecordsSection({
- jurisdiction,
- meetingId,
- meetingTitle,
-}: VotingRecordsSectionProps) {
- const { theme } = useTheme();
- const [expandedItem, setExpandedItem] = useState(null);
-
- const itemsQuery = useQuery(
- trpc.legistar.getMeetingVotes.queryOptions({ jurisdiction, meetingId }),
- );
-
- const votesQuery = useQuery({
- ...trpc.legistar.getVotes.queryOptions({
- jurisdiction,
- eventItemId: expandedItem ?? 0,
- }),
- enabled: expandedItem !== null,
- });
-
- return (
-
-
- {meetingTitle ?? "Voting Records"}
-
-
- {itemsQuery.isLoading && (
-
- )}
-
- {itemsQuery.data
- ?.filter((item) => item.EventItemTitle ?? item.EventItemMatterFile)
- .map((item, index) => {
- const isExpanded = expandedItem === item.EventItemId;
-
- return (
-
-
- setExpandedItem(isExpanded ? null : item.EventItemId)
- }
- activeOpacity={0.8}
- >
-
-
- {item.EventItemMatterFile && (
-
- {item.EventItemMatterFile}
-
- )}
-
-
-
- {item.EventItemTitle ??
- item.EventItemMatterName ??
- "Untitled"}
-
- {item.EventItemTally && (
-
- Tally: {item.EventItemTally}
-
- )}
-
-
-
-
- {isExpanded && (
-
- {votesQuery.isLoading && (
-
- )}
- {votesQuery.data?.map((vote) => (
-
-
- {vote.VotePersonName}
-
-
- {vote.VoteValueName}
-
-
- ))}
- {votesQuery.data?.length === 0 && (
-
- No individual votes recorded
-
- )}
-
- )}
-
- );
- })}
-
- {itemsQuery.data?.length === 0 && (
- No voting records available
- )}
-
- );
-}
-
-const colors = {
- white: "#FFFFFF",
- civicBlue: "#4A7CFF",
- textMuted: "#8A8FA0",
-};
-
-const styles = StyleSheet.create({
- container: {
- marginHorizontal: sp[4],
- marginBottom: sp[6],
- },
- sectionTitle: {
- fontFamily: fontEditorial.bold,
- fontSize: fontSize.lg,
- color: colors.white,
- marginBottom: sp[3],
- },
- loader: {
- marginVertical: sp[6],
- },
- card: {
- flexDirection: "row",
- alignItems: "center",
- borderRadius: rd.md,
- marginBottom: sp[2],
- padding: sp[4],
- },
- cardContent: {
- flex: 1,
- },
- meta: {
- flexDirection: "row",
- justifyContent: "space-between",
- alignItems: "center",
- marginBottom: sp[1],
- },
- file: {
- fontFamily: fontBody.semibold,
- fontSize: 10,
- color: colors.civicBlue,
- },
- title: {
- fontFamily: fontBody.medium,
- fontSize: fontSize.sm,
- color: colors.white,
- marginBottom: sp[1],
- },
- tally: {
- fontFamily: fontBody.regular,
- fontSize: fontSize.xs,
- color: colors.textMuted,
- },
- votesPanel: {
- marginBottom: sp[2],
- marginTop: -sp[1],
- borderBottomLeftRadius: rd.md,
- borderBottomRightRadius: rd.md,
- paddingHorizontal: sp[4],
- paddingVertical: sp[3],
- },
- voteRow: {
- flexDirection: "row",
- justifyContent: "space-between",
- paddingVertical: sp[1],
- },
- voterName: {
- fontFamily: fontBody.regular,
- fontSize: fontSize.xs,
- color: colors.white,
- },
- voteValue: {
- fontFamily: fontBody.semibold,
- fontSize: fontSize.xs,
- },
- noVotes: {
- fontFamily: fontBody.regular,
- fontSize: fontSize.xs,
- color: colors.textMuted,
- textAlign: "center",
- },
- noData: {
- fontFamily: fontBody.regular,
- fontSize: fontSize.sm,
- color: colors.textMuted,
- textAlign: "center",
- marginVertical: sp[6],
- },
-});
diff --git a/apps/expo/src/components/local-government/DecisionCard.tsx b/apps/expo/src/components/local-government/DecisionCard.tsx
new file mode 100644
index 00000000..cc65fcaf
--- /dev/null
+++ b/apps/expo/src/components/local-government/DecisionCard.tsx
@@ -0,0 +1,190 @@
+/**
+ * Compact editorial card for one local decision occurrence row. The card is
+ * jurisdiction-neutral: everything location-specific arrives via props/data.
+ */
+import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
+
+import type { DecisionRow } from "~/utils/local-government";
+import { Icon } from "~/components/ui/Icon";
+import { colors, fontBody, fontEditorial, useTheme } from "~/styles";
+import {
+ classifyDecision,
+ formatMeetingDate,
+ relativeDay,
+ scopeInfo,
+ topicLabel,
+} from "~/utils/local-government";
+import { LifecycleChip } from "./LifecycleChip";
+
+export function DecisionCard({
+ decision,
+ onPress,
+}: {
+ decision: DecisionRow;
+ onPress: () => void;
+}) {
+ const { theme } = useTheme();
+ const lifecycle = classifyDecision({
+ status: decision.status,
+ type: decision.type,
+ outcome: decision.outcome,
+ passed: decision.passed,
+ meetingCancelled: decision.meetingCancelled,
+ meetingStartsAt: decision.meetingStartsAt,
+ });
+ const scope = scopeInfo(
+ decision.scope,
+ decision.districtNumbers,
+ decision.geographicText,
+ );
+ const topic = topicLabel(decision.topic);
+ const when = formatMeetingDate(decision.meetingStartsAt);
+ const relative = relativeDay(decision.meetingStartsAt);
+
+ return (
+
+
+
+
+
+ {topic ? (
+
+ {topic}
+
+ ) : null}
+
+
+ {/* Official title — shown as-is; we never invent a summary for it. */}
+
+ {decision.title}
+
+
+
+
+
+
+ {decision.body}
+
+
+
+
+
+ {when}
+ {relative ? ` · ${relative}` : ""}
+
+
+
+
+
+
+ {decision.fileNumber ? (
+
+ File {decision.fileNumber}
+ {decision.agendaNumber
+ ? ` · Agenda ${decision.agendaNumber}`
+ : ""}
+
+ ) : null}
+ {scope.label ? (
+
+
+
+ {scope.label}
+
+
+ ) : null}
+
+
+
+
+
+ );
+}
+
+const s = StyleSheet.create({
+ card: {
+ flexDirection: "row",
+ borderRadius: 14,
+ borderWidth: StyleSheet.hairlineWidth,
+ overflow: "hidden",
+ },
+ spine: { width: 3 },
+ content: { flex: 1, padding: 14 },
+ metaRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 10,
+ marginBottom: 8,
+ },
+ metaText: {
+ fontFamily: fontBody.medium,
+ fontSize: 11.5,
+ flexShrink: 1,
+ },
+ title: {
+ fontFamily: fontEditorial.bold,
+ fontSize: 15.5,
+ lineHeight: 21,
+ marginBottom: 10,
+ },
+ facts: { gap: 5, marginBottom: 10 },
+ fact: { flexDirection: "row", alignItems: "center", gap: 6 },
+ factText: {
+ fontFamily: fontBody.medium,
+ fontSize: 12.5,
+ flexShrink: 1,
+ },
+ footerRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ gap: 8,
+ },
+ fileWrap: {
+ flex: 1,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 8,
+ minWidth: 0,
+ },
+ file: {
+ fontFamily: fontBody.regular,
+ fontSize: 11.5,
+ flexShrink: 1,
+ },
+ scopeBadge: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 4,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderRadius: 999,
+ paddingHorizontal: 7,
+ paddingVertical: 2,
+ },
+ scopeText: {
+ fontFamily: fontBody.semibold,
+ fontSize: 10.5,
+ maxWidth: 150,
+ },
+});
diff --git a/apps/expo/src/components/local-government/DecisionSkeletons.tsx b/apps/expo/src/components/local-government/DecisionSkeletons.tsx
new file mode 100644
index 00000000..ed2c631b
--- /dev/null
+++ b/apps/expo/src/components/local-government/DecisionSkeletons.tsx
@@ -0,0 +1,58 @@
+/**
+ * Loading placeholders mirroring the decision card's rhythm, so list and
+ * detail screens don't jump when data arrives.
+ */
+import { StyleSheet, View } from "react-native";
+
+import { useTheme } from "~/styles";
+
+export function DecisionCardSkeleton() {
+ const { theme } = useTheme();
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function DecisionListSkeleton({ count = 4 }: { count?: number }) {
+ return (
+
+ {Array.from({ length: count }, (_, i) => (
+
+ ))}
+
+ );
+}
+
+const s = StyleSheet.create({
+ stack: { gap: 12 },
+ card: {
+ flexDirection: "row",
+ borderRadius: 14,
+ borderWidth: StyleSheet.hairlineWidth,
+ overflow: "hidden",
+ },
+ spine: { width: 3 },
+ content: { flex: 1, padding: 14, gap: 9 },
+ chipRow: {
+ height: 18,
+ width: 150,
+ borderRadius: 999,
+ },
+ line: { height: 12, borderRadius: 6 },
+ titleLine: { height: 16, width: "92%" },
+ lineShort: { height: 12, borderRadius: 6, width: "55%" },
+});
diff --git a/apps/expo/src/components/local-government/DocumentsSection.tsx b/apps/expo/src/components/local-government/DocumentsSection.tsx
new file mode 100644
index 00000000..3c0879fa
--- /dev/null
+++ b/apps/expo/src/components/local-government/DocumentsSection.tsx
@@ -0,0 +1,228 @@
+/**
+ * Official documents, public-comment privacy note, and participation
+ * guidance. Public-comment letters are link-only by policy: we show the
+ * official link and a count — never names, attachment contents, or summaries.
+ */
+import { StyleSheet, Text, View } from "react-native";
+
+import type { DecisionDetail } from "~/utils/local-government";
+import { ExternalLink } from "~/components/ExternalLink";
+import { Icon } from "~/components/ui/Icon";
+import { colors, fontBody, useTheme } from "~/styles";
+import { documentCategoryLabel } from "~/utils/local-government";
+
+type Document = DecisionDetail["documents"][number];
+
+export function DocumentsSection({
+ documents,
+ publicComments,
+}: {
+ documents: readonly Document[];
+ publicComments: DecisionDetail["publicComments"];
+}) {
+ const { theme } = useTheme();
+
+ return (
+
+ {documents.length > 0 ? (
+ <>
+
+ Official documents
+
+
+ {documents.map((doc, index) => (
+
+ 0 && {
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: theme.border,
+ },
+ ]}
+ >
+
+
+
+ {documentCategoryLabel(doc.category)}
+ {doc.pageCount ? ` · ${doc.pageCount} pages` : ""}
+
+ {doc.description ? (
+
+ {doc.description}
+
+ ) : null}
+
+
+
+
+ ))}
+
+ >
+ ) : null}
+
+ {publicComments.documentCount > 0 ? (
+
+
+
+
+ {publicComments.documentCount} public comment{" "}
+ {publicComments.documentCount === 1 ? "letter" : "letters"} in the
+ official record
+
+
+ Billion links to these letters without displaying commenter names
+ or personal details.
+
+ {publicComments.officialLinks[0] ? (
+
+
+ View in the official record
+
+
+ ) : null}
+
+
+ ) : null}
+
+ );
+}
+
+export function ParticipationCard({
+ participation,
+}: {
+ participation: DecisionDetail["participation"];
+}) {
+ const { theme } = useTheme();
+
+ return (
+
+
+
+
+ How to participate
+
+
+
+ General guidance for this city — not instructions for this specific
+ item.
+
+ {participation.note ? (
+
+ {participation.note}
+
+ ) : null}
+ {participation.instructionsUrl ? (
+
+
+
+ Check the current agenda for how to comment
+
+
+
+
+ ) : null}
+
+ );
+}
+
+const s = StyleSheet.create({
+ wrap: { gap: 12 },
+ sectionLabel: {
+ fontFamily: fontBody.semibold,
+ fontSize: 11,
+ textTransform: "uppercase",
+ letterSpacing: 0.8,
+ marginBottom: 8,
+ },
+ docCard: {
+ borderRadius: 12,
+ borderWidth: StyleSheet.hairlineWidth,
+ overflow: "hidden",
+ },
+ docRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 10,
+ paddingVertical: 11,
+ paddingHorizontal: 12,
+ },
+ docText: { flex: 1, minWidth: 0 },
+ docCategory: {
+ fontFamily: fontBody.medium,
+ fontSize: 13,
+ },
+ docDesc: {
+ fontFamily: fontBody.regular,
+ fontSize: 11.5,
+ marginTop: 1,
+ },
+ privacyNote: {
+ flexDirection: "row",
+ gap: 10,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderRadius: 12,
+ padding: 12,
+ },
+ privacyTitle: {
+ fontFamily: fontBody.semibold,
+ fontSize: 12.5,
+ },
+ privacyBody: {
+ fontFamily: fontBody.regular,
+ fontSize: 12,
+ lineHeight: 17,
+ marginTop: 2,
+ },
+ link: {
+ fontFamily: fontBody.semibold,
+ fontSize: 12.5,
+ marginTop: 6,
+ },
+ participationCard: {
+ borderRadius: 12,
+ padding: 14,
+ gap: 8,
+ },
+ participationHead: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 7,
+ },
+ participationBody: {
+ fontFamily: fontBody.medium,
+ fontSize: 13,
+ lineHeight: 18,
+ },
+ participationNote: {
+ fontFamily: fontBody.regular,
+ fontSize: 12,
+ lineHeight: 17,
+ },
+ participationButton: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 6,
+ backgroundColor: colors.white,
+ borderRadius: 10,
+ paddingVertical: 10,
+ marginTop: 4,
+ },
+ participationButtonText: {
+ fontFamily: fontBody.semibold,
+ fontSize: 12.5,
+ color: colors.black,
+ },
+});
diff --git a/apps/expo/src/components/local-government/LifecycleChip.tsx b/apps/expo/src/components/local-government/LifecycleChip.tsx
new file mode 100644
index 00000000..8d8d6340
--- /dev/null
+++ b/apps/expo/src/components/local-government/LifecycleChip.tsx
@@ -0,0 +1,75 @@
+/**
+ * Lifecycle status chip for local-government decisions. Combines an icon,
+ * plain-language label, and tint so states never rely on color alone.
+ */
+import { StyleSheet, Text, View } from "react-native";
+
+import type { Theme } from "~/styles";
+import type { DecisionLifecycle } from "~/utils/local-government";
+import { Icon } from "~/components/ui/Icon";
+import { colors, fontBody, useTheme } from "~/styles";
+import {
+ lifecycleAccessibilityLabel,
+ lifecycleLabel,
+ lifecycleVisual,
+} from "~/utils/local-government";
+
+const TINT: Record<
+ "accent" | "success" | "warning" | "danger" | "muted",
+ (theme: Theme) => string
+> = {
+ accent: () => colors.bill,
+ success: () => colors.green[500],
+ warning: () => colors.yellow[500],
+ danger: () => colors.red[400],
+ muted: (theme) => theme.textSecondary,
+};
+
+export function LifecycleChip({
+ lifecycle,
+ size = "sm",
+}: {
+ lifecycle: DecisionLifecycle;
+ size?: "sm" | "md";
+}) {
+ const { theme } = useTheme();
+ const visual = lifecycleVisual(lifecycle);
+ const tint = TINT[visual.tint](theme);
+ const iconSize = size === "md" ? 14 : 11;
+
+ return (
+
+
+
+ {lifecycleLabel(lifecycle)}
+
+
+ );
+}
+
+const s = StyleSheet.create({
+ chip: {
+ flexDirection: "row",
+ alignItems: "center",
+ alignSelf: "flex-start",
+ gap: 5,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderRadius: 999,
+ },
+ sm: { paddingHorizontal: 8, paddingVertical: 3 },
+ md: { paddingHorizontal: 10, paddingVertical: 5 },
+ label: {
+ fontFamily: fontBody.semibold,
+ fontSize: 10.5,
+ letterSpacing: 0.2,
+ },
+ smLabel: { fontSize: 10.5 },
+ mdLabel: { fontSize: 12 },
+});
diff --git a/apps/expo/src/components/local-government/OccurrenceTimeline.tsx b/apps/expo/src/components/local-government/OccurrenceTimeline.tsx
new file mode 100644
index 00000000..e2306c12
--- /dev/null
+++ b/apps/expo/src/components/local-government/OccurrenceTimeline.tsx
@@ -0,0 +1,252 @@
+/**
+ * Chronological timeline of every meeting at which a decision appeared.
+ * Each occurrence can carry its own agenda number, action, tally and votes.
+ */
+import { StyleSheet, Text, View } from "react-native";
+
+import type { DecisionDetail } from "~/utils/local-government";
+import { ExternalLink } from "~/components/ExternalLink";
+import { colors, fontBody, useTheme } from "~/styles";
+import {
+ formatMeetingDateTime,
+ groupVotesByOccurrence,
+ sortTimeline,
+ voteAvailability,
+ voteValueLabel,
+ voteValueTone,
+} from "~/utils/local-government";
+
+type Occurrence = DecisionDetail["occurrences"][number];
+
+export function OccurrenceTimeline({
+ occurrences,
+ votes,
+}: {
+ occurrences: readonly Occurrence[];
+ votes: DecisionDetail["votes"];
+}) {
+ const grouped = groupVotesByOccurrence(votes);
+ const ordered = sortTimeline(occurrences);
+
+ return (
+
+ {ordered.map((occurrence, index) => (
+
+ ))}
+
+ );
+}
+
+function OccurrenceRow({
+ occurrence,
+ isLast,
+ votes,
+}: {
+ occurrence: Occurrence;
+ isLast: boolean;
+ votes: DecisionDetail["votes"];
+}) {
+ const { theme } = useTheme();
+ const availability = voteAvailability(occurrence, votes.length > 0);
+
+ return (
+
+ {/* Rail */}
+
+
+ {!isLast && (
+
+ )}
+
+
+ {/* Body */}
+
+
+ {formatMeetingDateTime(occurrence.startsAt)}
+ {occurrence.cancelled ? " · Cancelled" : ""}
+
+
+ {occurrence.body}
+
+ {occurrence.agendaNumber ? (
+
+ Agenda item {occurrence.agendaNumber}
+
+ ) : null}
+
+ {occurrence.action ? (
+
+
+ Action taken
+
+
+ {occurrence.action}
+
+ {occurrence.tally ? (
+
+ Result: {occurrence.tally}
+
+ ) : null}
+
+ ) : occurrence.proposedAction ? (
+
+
+ Recommended action
+
+
+ {occurrence.proposedAction}
+
+
+ ) : null}
+
+ {availability.visible ? (
+
+
+ {availability.headline}
+
+ {votes.map((vote) => {
+ const tone = voteValueTone(vote.value);
+ const tint =
+ tone === "for"
+ ? colors.green[500]
+ : tone === "against"
+ ? colors.red[400]
+ : theme.textSecondary;
+ return (
+
+
+ {vote.personName}
+
+
+ {voteValueLabel(vote.value)}
+
+
+ );
+ })}
+
+ ) : (
+ availability.detail && (
+
+ {availability.headline}. {availability.detail}
+
+ )
+ )}
+
+
+ {occurrence.agendaUrl ? (
+
+
+ Meeting agenda
+
+
+ ) : null}
+ {occurrence.minutesUrl ? (
+
+ Minutes
+
+ ) : null}
+
+
+
+ );
+}
+
+const s = StyleSheet.create({
+ wrap: {},
+ row: { flexDirection: "row" },
+ rail: { width: 18, alignItems: "center" },
+ dot: {
+ width: 10,
+ height: 10,
+ borderRadius: 5,
+ marginTop: 5,
+ },
+ stem: { flex: 1, width: StyleSheet.hairlineWidth, marginVertical: 4 },
+ body: { flex: 1, paddingLeft: 10, paddingBottom: 22 },
+ date: {
+ fontFamily: fontBody.semibold,
+ fontSize: 13,
+ },
+ bodyName: {
+ fontFamily: fontBody.medium,
+ fontSize: 13,
+ marginTop: 2,
+ },
+ meta: {
+ fontFamily: fontBody.regular,
+ fontSize: 12,
+ marginTop: 2,
+ },
+ actionBox: {
+ borderRadius: 10,
+ padding: 10,
+ marginTop: 8,
+ gap: 4,
+ },
+ actionLabel: {
+ fontFamily: fontBody.semibold,
+ fontSize: 10,
+ textTransform: "uppercase",
+ letterSpacing: 0.6,
+ },
+ actionText: {
+ fontFamily: fontBody.medium,
+ fontSize: 13,
+ lineHeight: 18,
+ },
+ tally: {
+ fontFamily: fontBody.semibold,
+ fontSize: 12.5,
+ marginTop: 2,
+ },
+ voteRow: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ gap: 12,
+ },
+ voteName: {
+ fontFamily: fontBody.regular,
+ fontSize: 12.5,
+ flexShrink: 1,
+ },
+ voteValue: {
+ fontFamily: fontBody.semibold,
+ fontSize: 12.5,
+ },
+ noVotes: {
+ fontFamily: fontBody.regular,
+ fontSize: 12,
+ lineHeight: 17,
+ marginTop: 8,
+ },
+ links: {
+ flexDirection: "row",
+ gap: 16,
+ marginTop: 8,
+ },
+ link: {
+ fontFamily: fontBody.semibold,
+ fontSize: 12.5,
+ },
+});
diff --git a/apps/expo/src/components/local-government/index.ts b/apps/expo/src/components/local-government/index.ts
new file mode 100644
index 00000000..51f6c704
--- /dev/null
+++ b/apps/expo/src/components/local-government/index.ts
@@ -0,0 +1,8 @@
+export { LifecycleChip } from "./LifecycleChip";
+export { DecisionCard } from "./DecisionCard";
+export {
+ DecisionListSkeleton,
+ DecisionCardSkeleton,
+} from "./DecisionSkeletons";
+export { OccurrenceTimeline } from "./OccurrenceTimeline";
+export { DocumentsSection, ParticipationCard } from "./DocumentsSection";
diff --git a/apps/expo/src/components/ui/Icon.tsx b/apps/expo/src/components/ui/Icon.tsx
index 7e78ff48..72f9e119 100644
--- a/apps/expo/src/components/ui/Icon.tsx
+++ b/apps/expo/src/components/ui/Icon.tsx
@@ -52,7 +52,10 @@ type IconName =
| "arrowRight"
| "minus"
| "quote"
- | "book";
+ | "book"
+ | "users"
+ | "mic"
+ | "link";
type Family = "ion" | "feather" | "fa";
@@ -102,6 +105,9 @@ const MAP: Record = {
minus: { family: "feather", name: "minus" },
quote: { family: "fa", name: "quote-left" },
book: { family: "feather", name: "book-open" },
+ users: { family: "feather", name: "users" },
+ mic: { family: "feather", name: "mic" },
+ link: { family: "feather", name: "link" },
};
export interface IconProps {
diff --git a/apps/expo/src/components/ui/NavHeader.tsx b/apps/expo/src/components/ui/NavHeader.tsx
index 593665ee..57ac1698 100644
--- a/apps/expo/src/components/ui/NavHeader.tsx
+++ b/apps/expo/src/components/ui/NavHeader.tsx
@@ -3,7 +3,7 @@ import type { ReactNode } from "react";
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
-import { colors, fontDisplay, hair, planes } from "~/styles";
+import { colors, fontDisplay, hair, planes, useTheme } from "~/styles";
import { Icon } from "./Icon";
export function NavHeader({
@@ -18,6 +18,7 @@ export function NavHeader({
large?: boolean;
}) {
const insets = useSafeAreaInsets();
+ const { theme } = useTheme();
return (
@@ -28,15 +29,19 @@ export function NavHeader({
activeOpacity={0.7}
hitSlop={8}
>
-
+
) : (
)}
- {!large && {title}}
+ {!large && (
+ {title}
+ )}
{action}
- {large && {title}}
+ {large && (
+ {title}
+ )}
);
}
diff --git a/apps/expo/src/components/ui/primitives.tsx b/apps/expo/src/components/ui/primitives.tsx
index 19ca192a..5b11e6f9 100644
--- a/apps/expo/src/components/ui/primitives.tsx
+++ b/apps/expo/src/components/ui/primitives.tsx
@@ -23,6 +23,7 @@ import {
fontSize,
hair,
planes,
+ useTheme,
} from "~/styles";
import { Icon } from "./Icon";
@@ -193,13 +194,17 @@ export function Pill({
onPress?: () => void;
icon?: IconName;
}) {
+ const { theme } = useTheme();
return (
)}
{label}
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..87e0ca0d
--- /dev/null
+++ b/apps/expo/src/utils/local-government.test.ts
@@ -0,0 +1,264 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+
+import {
+ classifyDecision,
+ detectJurisdictionKey,
+ documentCategoryLabel,
+ formatMeetingDate,
+ groupVotesByOccurrence,
+ lifecycleLabel,
+ scopeInfo,
+ sortTimeline,
+ timelineSummary,
+ topicLabel,
+ truncate,
+ voteAvailability,
+ voteValueLabel,
+} from "./local-government";
+
+const FUTURE = new Date("2026-12-01T17:00:00-08:00");
+const PAST = new Date("2026-06-01T17:00:00-08:00");
+const NOW = new Date("2026-08-24T12:00:00-07:00");
+
+describe("classifyDecision", () => {
+ it("treats a future meeting as upcoming", () => {
+ const result = classifyDecision(
+ { meetingStartsAt: FUTURE, status: "Pending" },
+ NOW,
+ );
+ assert.equal(result, "upcoming");
+ assert.equal(lifecycleLabel(result), "Scheduled for a public meeting");
+ });
+
+ it("treats past meetings without outcomes as awaiting outcome, not decided", () => {
+ const result = classifyDecision(
+ { meetingStartsAt: PAST, status: "Pending", outcome: null },
+ NOW,
+ );
+ assert.equal(result, "awaiting_outcome");
+ assert.match(lifecycleLabel(result), /outcome not yet published/i);
+ });
+
+ it("maps approval language to approved", () => {
+ assert.equal(classifyDecision({ outcome: "Adopted" }, NOW), "approved");
+ assert.equal(
+ classifyDecision({ status: "Adopted", outcome: "" }, NOW),
+ "approved",
+ );
+ assert.equal(classifyDecision({ passed: "Pass" }, NOW), "approved");
+ });
+
+ it("maps rejection language to not approved", () => {
+ assert.equal(classifyDecision({ outcome: "Failed" }, NOW), "rejected");
+ assert.equal(
+ classifyDecision({ outcome: "Deny the appeal" }, NOW),
+ "rejected",
+ );
+ });
+
+ it("maps deferred and withdrawn language", () => {
+ assert.equal(classifyDecision({ outcome: "Continued" }, NOW), "deferred");
+ assert.equal(classifyDecision({ status: "Withdrawn" }, NOW), "withdrawn");
+ });
+
+ it("maps cancelled meetings without recorded action", () => {
+ const result = classifyDecision(
+ { meetingCancelled: true, meetingStartsAt: PAST },
+ NOW,
+ );
+ assert.equal(result, "cancelled");
+ });
+
+ it("keeps informational items distinct", () => {
+ assert.equal(
+ classifyDecision(
+ { type: "Informational Report", meetingStartsAt: FUTURE },
+ NOW,
+ ),
+ "informational",
+ );
+ });
+
+ it("reports unknown action language as decided_other instead of guessing", () => {
+ assert.equal(
+ classifyDecision({ outcome: "Referred to committee" }, NOW),
+ "decided_other",
+ );
+ });
+});
+
+describe("scope display", () => {
+ it("labels citywide scope", () => {
+ const info = scopeInfo("citywide", null, null);
+ assert.equal(info.label, "Citywide");
+ assert.match(info.sentence ?? "", /whole city/);
+ });
+
+ it("renders district numbers plainly", () => {
+ const info = scopeInfo("district", [3], "Council District 3");
+ assert.equal(info.label, "District 3");
+ assert.equal(info.sentence, "Affects District 3.");
+ });
+
+ it("supports multiple districts", () => {
+ const info = scopeInfo("district", [2, 7], null);
+ assert.equal(info.label, "District 2 · District 7");
+ });
+
+ it("uses place text for place scope", () => {
+ const info = scopeInfo("place", null, "100 N Market St");
+ assert.equal(info.kind, "place");
+ assert.ok((info.label ?? "").length > 0);
+ });
+
+ it("returns no label when geography is unknown", () => {
+ const info = scopeInfo("unknown", null, null);
+ assert.equal(info.label, null);
+ assert.equal(info.sentence, null);
+ });
+});
+
+describe("timeline ordering", () => {
+ it("sorts occurrences chronologically with undated rows last", () => {
+ const sorted = sortTimeline([
+ { startsAt: PAST },
+ { startsAt: null },
+ { startsAt: FUTURE },
+ ]);
+ assert.deepEqual(
+ sorted.map((o) => o.startsAt),
+ [PAST, FUTURE, null],
+ );
+ });
+
+ it("does not mutate its input", () => {
+ const input = [{ startsAt: FUTURE }, { startsAt: PAST }];
+ sortTimeline(input);
+ assert.equal(input[0]?.startsAt, FUTURE);
+ });
+
+ it("summarizes occurrences for cards", () => {
+ const summary = timelineSummary([
+ {
+ id: "1",
+ startsAt: FUTURE,
+ body: "City Council",
+ agendaNumber: "3.1",
+ action: null,
+ tally: null,
+ cancelled: false,
+ } as never,
+ ]);
+ assert.equal(summary[0]?.body ?? "", "City Council");
+ assert.equal(summary[0]?.agendaNumber ?? null, "3.1");
+ });
+});
+
+describe("vote availability language", () => {
+ it("shows votes when published", () => {
+ const availability = voteAvailability(
+ { action: "Approved", tally: "5-2" },
+ true,
+ );
+ assert.equal(availability.visible, true);
+ });
+
+ it("never implies missing votes mean nobody voted (with tally)", () => {
+ const availability = voteAvailability(
+ { action: "Approved", tally: "5-2" },
+ false,
+ );
+ assert.equal(availability.visible, false);
+ assert.match(
+ availability.headline,
+ /Individual votes have not been published/,
+ );
+ assert.match(availability.detail ?? "", /5-2/);
+ });
+
+ it("never implies missing votes mean nobody voted (without tally)", () => {
+ const availability = voteAvailability(
+ { action: "Approved", tally: null },
+ false,
+ );
+ assert.equal(availability.visible, false);
+ assert.match(
+ availability.headline,
+ /Individual votes have not been published/,
+ );
+ });
+
+ it("distinguishes no-vote-yet for unheard items", () => {
+ const availability = voteAvailability({ action: null, tally: null }, false);
+ assert.match(availability.headline, /No vote recorded yet/);
+ });
+
+ it("normalizes vote values", () => {
+ assert.equal(voteValueLabel("yes"), "Yes");
+ assert.equal(voteValueLabel("NAY"), "No");
+ assert.equal(voteValueLabel("Abstaining"), "Abstained");
+ assert.equal(voteValueLabel("Recused"), "Recused");
+ });
+
+ it("groups votes under their occurrence", () => {
+ const grouped = groupVotesByOccurrence([
+ { meetingItemId: "a", personName: "A", value: "Yes", sort: 1 },
+ { meetingItemId: "b", personName: "B", value: "No", sort: 1 },
+ { meetingItemId: "a", personName: "C", value: "Yes", sort: 2 },
+ ]);
+ assert.equal(grouped.get("a")?.length, 2);
+ assert.equal(grouped.get("b")?.length, 1);
+ });
+});
+
+describe("jurisdiction detection", () => {
+ it("defaults to san jose when nothing is saved", () => {
+ assert.equal(detectJurisdictionKey(null), "sanjose");
+ assert.equal(detectJurisdictionKey(undefined), "sanjose");
+ });
+
+ it("detects san jose addresses", () => {
+ assert.equal(
+ detectJurisdictionKey("200 E Santa Clara St, San Jose, CA"),
+ "sanjose",
+ );
+ });
+
+ it("separates county and neighboring cities", () => {
+ assert.equal(detectJurisdictionKey("Santa Clara County"), "santaclara");
+ assert.equal(detectJurisdictionKey("Sunnyvale, CA"), "sunnyvale");
+ });
+});
+
+describe("topics and documents", () => {
+ it("de-jargonizes topics", () => {
+ assert.equal(topicLabel("housing-land-use"), "Housing & land use");
+ assert.equal(topicLabel("budget-finance"), "Budget, fees & contracts");
+ });
+
+ it("passes unknown topics through cleanly", () => {
+ assert.equal(topicLabel(null), null);
+ assert.equal(topicLabel("something_new"), "Something New");
+ });
+
+ it("labels document categories", () => {
+ assert.equal(documentCategoryLabel("staff_report"), "Staff report");
+ assert.equal(documentCategoryLabel("minutes_order"), "Minutes order");
+ assert.equal(documentCategoryLabel("mystery"), "Mystery");
+ });
+});
+
+describe("partial-data tolerance", () => {
+ it("formats absent dates honestly", () => {
+ assert.equal(formatMeetingDate(null), "Date not published");
+ assert.equal(formatMeetingDate("not-a-date"), "Date not published");
+ });
+
+ it("truncates long titles without breaking characters", () => {
+ const long = "Ordinance of the City of San José amending ".repeat(4);
+ const cut = truncate(long, 60);
+ assert.ok(cut.length <= 60);
+ assert.ok(cut.endsWith("…"));
+ });
+});
diff --git a/apps/expo/src/utils/local-government.ts b/apps/expo/src/utils/local-government.ts
new file mode 100644
index 00000000..3fca9ce5
--- /dev/null
+++ b/apps/expo/src/utils/local-government.ts
@@ -0,0 +1,517 @@
+/**
+ * Jurisdiction-neutral presentation logic for local-government decisions.
+ *
+ * Everything here is a pure function over API-shaped data so it can be unit
+ * tested without React Native. Types come from the real tRPC outputs — do not
+ * hand-mock shapes here.
+ */
+import type { RouterOutputs } from "~/utils/api";
+
+export type DecisionListPage = RouterOutputs["legistar"]["listDecisions"];
+export type DecisionRow = DecisionListPage[number];
+export type DecisionDetail = RouterOutputs["legistar"]["getDecision"];
+export type DecisionOccurrence = DecisionDetail["occurrences"][number];
+export type DecisionVote = DecisionDetail["votes"][number];
+
+/** First-release jurisdictions wired into the ingestion pipeline. */
+export const LOCAL_JURISDICTIONS = [
+ "sanjose",
+ "santaclara",
+ "sunnyvale",
+] as const;
+export type LocalJurisdictionKey = (typeof LOCAL_JURISDICTIONS)[number];
+
+/**
+ * Best-effort jurisdiction detection from a saved address string. Falls back
+ * to the first-release default rather than pretending every address is
+ * covered. City names are matched conservatively; county wording maps to the
+ * county record.
+ */
+export function detectJurisdictionKey(
+ address: string | null | undefined,
+): LocalJurisdictionKey {
+ if (!address) return "sanjose";
+ const normalized = address.toLowerCase();
+ // City names are checked before street parsing so a street like
+ // "Santa Clara St, San Jose" stays San José.
+ if (/san jose|san jos[eé]/.test(normalized)) return "sanjose";
+ if (normalized.includes("sunnyvale")) return "sunnyvale";
+ if (/santa clara county|unincorporated/.test(normalized)) return "santaclara";
+ return "sanjose";
+}
+
+/** Best-effort display name while the DB record loads. */
+export const JURISDICTION_FALLBACK_NAMES: Record =
+ {
+ sanjose: "San José",
+ santaclara: "Santa Clara County",
+ sunnyvale: "Sunnyvale",
+ };
+
+// ---------------------------------------------------------------------------
+// Topics
+// ---------------------------------------------------------------------------
+
+const TOPIC_LABELS: Record = {
+ "housing-land-use": "Housing & land use",
+ transportation: "Transportation",
+ "public-safety": "Public safety",
+ "budget-finance": "Budget, fees & contracts",
+ "environment-utilities": "Environment & utilities",
+ "community-services": "Neighborhood services",
+ "ethics-government": "Ethics & open government",
+ other: "Other",
+};
+
+/** Plain-language topic label; unknown keys pass through de-jargonized. */
+export function topicLabel(topic: string | null | undefined): string | null {
+ if (!topic) return null;
+ return TOPIC_LABELS[topic] ?? titleCase(topic);
+}
+
+export function topicLabelOrFallback(topic: string): string {
+ return TOPIC_LABELS[topic] ?? titleCase(topic);
+}
+
+function titleCase(slug: string): string {
+ return slug
+ .split(/[-_]/)
+ .filter(Boolean)
+ .map((word) => word[0]?.toUpperCase() + word.slice(1))
+ .join(" ");
+}
+
+// ---------------------------------------------------------------------------
+// Geographic scope
+// ---------------------------------------------------------------------------
+
+export interface ScopeInfo {
+ kind: "citywide" | "district" | "place" | "unknown";
+ /** Short badge label, or null when unknown. */
+ label: string | null;
+ /** Fuller sentence for detail screens. */
+ sentence: string | null;
+}
+
+/** Normalize a row/detail's scope fields into display info. */
+export function scopeInfo(
+ scopeKind: string,
+ districtNumbers: readonly number[] | null,
+ geographicText: string | null | undefined,
+): ScopeInfo {
+ switch (scopeKind) {
+ case "citywide":
+ return {
+ kind: "citywide",
+ label: "Citywide",
+ sentence: "Affects the whole city.",
+ };
+ case "district": {
+ const districts = (districtNumbers ?? []).map((n) => `District ${n}`);
+ const label = districts.length
+ ? districts.join(" · ")
+ : "Council district";
+ return {
+ kind: "district",
+ label,
+ sentence: `Affects ${districts.join(" and ")}.`,
+ };
+ }
+ case "place": {
+ const place = geographicText?.trim() ?? null;
+ return {
+ kind: "place",
+ label: place ? truncate(place, 28) : "Specific location",
+ sentence: place ? `Affects the area around ${place}.` : null,
+ };
+ }
+ default:
+ return { kind: "unknown", label: null, sentence: null };
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Lifecycle status
+// ---------------------------------------------------------------------------
+
+export type DecisionLifecycle =
+ | "upcoming"
+ | "awaiting_outcome"
+ | "approved"
+ | "rejected"
+ | "deferred"
+ | "withdrawn"
+ | "cancelled"
+ | "informational"
+ | "decided_other"
+ | "unknown";
+
+interface LifecycleInput {
+ status?: string | null;
+ type?: string | null;
+ outcome?: string | null;
+ passed?: string | null;
+ meetingCancelled?: boolean;
+ meetingStartsAt?: Date | string | null;
+}
+
+const APPROVED_PATTERN =
+ /adopt(?:ed)?|approv(?:ed|al)|pass(?:ed)?|confirm(?:ed)?|enact(?:ed)?|carried|authorized|accepted|granted/i;
+const REJECTED_PATTERN =
+ /\b(?:denied|rejected?|failed|denied to pass|not approved|did not pass|deny)\b|fail(?:ed)? to pass/i;
+const DEFERRED_PATTERN =
+ /continu(?:ed|e)|deferr?ed|tabled|held over|postponed/i;
+const WITHDRAWN_PATTERN = /withdrawn|pulled|removed from agenda/i;
+const INFORMATIONAL_PATTERN =
+ /informational|report only|receive and file|presentation|briefing|update only/i;
+
+/** Classify one decision row into a lifecycle state for presentation. */
+export function classifyDecision(
+ input: LifecycleInput,
+ now: Date = new Date(),
+): DecisionLifecycle {
+ const outcome = input.outcome ?? "";
+ const passed = input.passed ?? "";
+ const combined = `${input.status ?? ""} ${outcome} ${passed}`;
+
+ if (WITHDRAWN_PATTERN.test(combined)) return "withdrawn";
+ if (input.meetingCancelled && !APPROVED_PATTERN.test(combined))
+ return "cancelled";
+ if (
+ DEFERRED_PATTERN.test(outcome) ||
+ DEFERRED_PATTERN.test(input.status ?? "")
+ )
+ return "deferred";
+ if (
+ INFORMATIONAL_PATTERN.test(outcome) ||
+ INFORMATIONAL_PATTERN.test(input.type ?? "")
+ )
+ return "informational";
+
+ const hasOutcome = Boolean(outcome.trim());
+ if (!hasOutcome && APPROVED_PATTERN.test(input.status ?? "")) {
+ // Matter-level status like "Adopted" with no recorded item action still
+ // counts as decided — the source says so even if the item row lags.
+ return "approved";
+ }
+ if (hasOutcome || passed.trim()) {
+ if (REJECTED_PATTERN.test(combined)) return "rejected";
+ if (DEFERRED_PATTERN.test(combined)) return "deferred";
+ if (INFORMATIONAL_PATTERN.test(outcome)) return "informational";
+ if (APPROVED_PATTERN.test(combined) || /^pass/i.test(passed.trim()))
+ return "approved";
+ // An action exists that matches neither approve nor reject language —
+ // report what the source said instead of guessing a side.
+ return "decided_other";
+ }
+
+ const startsAt = parseDate(input.meetingStartsAt);
+ if (startsAt && startsAt.getTime() >= now.getTime()) return "upcoming";
+ return "awaiting_outcome";
+}
+
+/** Plain-language status label. Never implies a vote happened when it didn't. */
+export function lifecycleLabel(lifecycle: DecisionLifecycle): string {
+ switch (lifecycle) {
+ case "upcoming":
+ return "Scheduled for a public meeting";
+ case "awaiting_outcome":
+ return "Heard — outcome not yet published";
+ case "approved":
+ return "Approved";
+ case "rejected":
+ return "Not approved";
+ case "deferred":
+ return "Deferred to a later meeting";
+ case "withdrawn":
+ return "Withdrawn";
+ case "cancelled":
+ return "Meeting cancelled";
+ case "informational":
+ return "Informational — no vote expected";
+ case "decided_other":
+ return "Action taken";
+ default:
+ return "Status unclear in official records";
+ }
+}
+
+/** Screen-reader-friendly full status sentence. */
+export function lifecycleAccessibilityLabel(
+ lifecycle: DecisionLifecycle,
+): string {
+ return `Status: ${lifecycleLabel(lifecycle)}`;
+}
+
+/**
+ * Visual treatment per lifecycle. Icon + shape + color so states stay
+ * distinguishable without relying on hue alone.
+ */
+export function lifecycleVisual(lifecycle: DecisionLifecycle): {
+ icon:
+ | "calendar"
+ | "clock"
+ | "check"
+ | "close"
+ | "undo"
+ | "info"
+ | "block"
+ | "help";
+ tint: "accent" | "success" | "warning" | "danger" | "muted";
+} {
+ switch (lifecycle) {
+ case "upcoming":
+ return { icon: "calendar", tint: "accent" };
+ case "awaiting_outcome":
+ return { icon: "clock", tint: "warning" };
+ case "approved":
+ return { icon: "check", tint: "success" };
+ case "rejected":
+ return { icon: "close", tint: "danger" };
+ case "deferred":
+ return { icon: "undo", tint: "warning" };
+ case "withdrawn":
+ return { icon: "block", tint: "muted" };
+ case "cancelled":
+ return { icon: "block", tint: "danger" };
+ case "informational":
+ return { icon: "info", tint: "muted" };
+ case "decided_other":
+ return { icon: "check", tint: "muted" };
+ default:
+ return { icon: "help", tint: "muted" };
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Timeline
+// ---------------------------------------------------------------------------
+
+/** Occurrences ordered chronologically; undated rows keep stable position last. */
+export function sortTimeline(
+ occurrences: readonly T[],
+): T[] {
+ return [...occurrences].sort((a, b) => {
+ const ta = parseDate(a.startsAt)?.getTime();
+ const tb = parseDate(b.startsAt)?.getTime();
+ if (ta === undefined && tb === undefined) return 0;
+ if (ta === undefined) return 1;
+ if (tb === undefined) return -1;
+ return ta - tb;
+ });
+}
+
+export interface OccurrenceSummary {
+ body: string;
+ date: Date | null;
+ agendaNumber: string | null;
+ action: string | null;
+ tally: string | null;
+ cancelled: boolean;
+}
+
+/** Compact chronological summary used for card subtitles. */
+export function timelineSummary(
+ occurrences: readonly DecisionOccurrence[],
+): OccurrenceSummary[] {
+ return sortTimeline(occurrences).map((occurrence) => ({
+ body: occurrence.body,
+ date: parseDate(occurrence.startsAt),
+ agendaNumber: occurrence.agendaNumber,
+ action: occurrence.action,
+ tally: occurrence.tally,
+ cancelled: occurrence.cancelled,
+ }));
+}
+
+// ---------------------------------------------------------------------------
+// Votes
+// ---------------------------------------------------------------------------
+
+/** Group flat vote rows under their meeting-item occurrence id. */
+export function groupVotesByOccurrence(
+ votes: readonly DecisionVote[],
+): Map {
+ const grouped = new Map();
+ for (const vote of votes) {
+ const bucket = grouped.get(vote.meetingItemId);
+ if (bucket) bucket.push(vote);
+ else grouped.set(vote.meetingItemId, [vote]);
+ }
+ return grouped;
+}
+
+/** Canonical short label for a recorded vote value. */
+export function voteValueLabel(value: string): string {
+ const normalized = value.trim().toLowerCase();
+ if (normalized.startsWith("y")) return "Yes";
+ if (normalized.startsWith("n")) return "No";
+ if (normalized.includes("abstain")) return "Abstained";
+ if (normalized.includes("absent")) return "Absent";
+ if (normalized.includes("recus")) return "Recused";
+ if (normalized.includes("excused")) return "Excused";
+ return value.trim();
+}
+
+export function voteValueTone(value: string): "for" | "against" | "neutral" {
+ const label = voteValueLabel(value);
+ if (label === "Yes") return "for";
+ if (label === "No") return "against";
+ return "neutral";
+}
+
+/**
+ * Honest copy for the votes section of an occurrence. A missing vote row
+ * means the publication hasn't happened — never that nobody voted.
+ */
+export function voteAvailability(
+ occurrence: Pick,
+ hasRecordedVotes: boolean,
+): {
+ visible: boolean;
+ headline: string;
+ detail: string | null;
+} {
+ if (hasRecordedVotes) {
+ return {
+ visible: true,
+ headline: "How officials voted",
+ detail: null,
+ };
+ }
+ if (occurrence.tally?.trim()) {
+ return {
+ visible: false,
+ headline: "Individual votes have not been published",
+ detail: `The meeting record shows a ${occurrence.tally.trim()} result, but named votes aren’t available yet.`,
+ };
+ }
+ if (occurrence.action?.trim()) {
+ return {
+ visible: false,
+ headline: "Individual votes have not been published",
+ detail:
+ "The outcome above comes from the official record; named votes weren’t published with this item.",
+ };
+ }
+ return {
+ visible: false,
+ headline: "No vote recorded yet",
+ detail:
+ "If this item was voted on, individual results will appear after the body publishes them.",
+ };
+}
+
+/**
+ * First non-cancelled occurrence at or after `now` (defaults to the current
+ * time). Kept here so components stay free of impure render-time calls.
+ */
+export function nextUpcomingOccurrence(
+ occurrences: readonly DecisionOccurrence[],
+ now: Date = new Date(),
+): DecisionOccurrence | null {
+ const time = now.getTime();
+ const ordered = sortTimeline(occurrences);
+ return (
+ ordered.find(
+ (o) => !o.cancelled && (parseDate(o.startsAt)?.getTime() ?? -1) >= time,
+ ) ?? null
+ );
+}
+
+/** Chronologically last occurrence (the most recent state of the matter). */
+export function latestOccurrence(
+ occurrences: readonly DecisionOccurrence[],
+): DecisionOccurrence | null {
+ return sortTimeline(occurrences).at(-1) ?? null;
+}
+
+// ---------------------------------------------------------------------------
+// Dates
+// ---------------------------------------------------------------------------
+
+export function parseDate(
+ value: Date | string | null | undefined,
+): Date | null {
+ if (value == null) return null;
+ const date = value instanceof Date ? value : new Date(value);
+ return Number.isNaN(date.getTime()) ? null : date;
+}
+
+export function formatMeetingDate(
+ date: Date | string | null | undefined,
+): string {
+ const parsed = parseDate(date);
+ if (!parsed) return "Date not published";
+ return parsed.toLocaleDateString("en-US", {
+ weekday: "short",
+ month: "short",
+ day: "numeric",
+ });
+}
+
+export function formatMeetingDateTime(date: Date | string | null): string {
+ const parsed = parseDate(date);
+ if (!parsed) return "Time not published";
+ const day = parsed.toLocaleDateString("en-US", {
+ weekday: "short",
+ month: "short",
+ day: "numeric",
+ });
+ const time = parsed.toLocaleTimeString("en-US", {
+ hour: "numeric",
+ minute: "2-digit",
+ });
+ return `${day} · ${time}`;
+}
+
+export function relativeDay(
+ date: Date | string | null,
+ now = new Date(),
+): string | null {
+ const parsed = parseDate(date);
+ if (!parsed) return null;
+ const days = Math.round(
+ (startOfDay(parsed).getTime() - startOfDay(now).getTime()) / 86_400_000,
+ );
+ if (days === 0) return "Today";
+ if (days === 1) return "Tomorrow";
+ if (days === -1) return "Yesterday";
+ if (days > 1 && days <= 13) return `In ${days} days`;
+ if (days < -1 && days >= -13) return `${Math.abs(days)} days ago`;
+ return null;
+}
+
+function startOfDay(date: Date): Date {
+ const copy = new Date(date);
+ copy.setHours(0, 0, 0, 0);
+ return copy;
+}
+
+// ---------------------------------------------------------------------------
+// Documents
+// ---------------------------------------------------------------------------
+
+const DOCUMENT_LABELS: Record = {
+ staff_report: "Staff report",
+ ordinance: "Ordinance",
+ resolution: "Resolution",
+ fiscal: "Fiscal document",
+ presentation: "Presentation",
+ minutes_order: "Minutes order",
+ reference: "Official reference",
+ other: "Document",
+};
+
+export function documentCategoryLabel(category: string): string {
+ return DOCUMENT_LABELS[category] ?? titleCase(category);
+}
+
+// ---------------------------------------------------------------------------
+// Misc
+// ---------------------------------------------------------------------------
+
+export function truncate(text: string, max: number): string {
+ if (text.length <= max) return text;
+ return `${text.slice(0, Math.max(0, max - 1)).trimEnd()}…`;
+}
diff --git a/artifacts/local-government-screens/01-entry-elections.png b/artifacts/local-government-screens/01-entry-elections.png
new file mode 100644
index 00000000..29eec254
Binary files /dev/null and b/artifacts/local-government-screens/01-entry-elections.png differ
diff --git a/artifacts/local-government-screens/02-list-upcoming-dark.png b/artifacts/local-government-screens/02-list-upcoming-dark.png
new file mode 100644
index 00000000..9e77db1a
Binary files /dev/null and b/artifacts/local-government-screens/02-list-upcoming-dark.png differ
diff --git a/artifacts/local-government-screens/03-list-recent-dark.png b/artifacts/local-government-screens/03-list-recent-dark.png
new file mode 100644
index 00000000..d66491ee
Binary files /dev/null and b/artifacts/local-government-screens/03-list-recent-dark.png differ
diff --git a/artifacts/local-government-screens/04-detail-approved-votes.png b/artifacts/local-government-screens/04-detail-approved-votes.png
new file mode 100644
index 00000000..4a64bb0d
Binary files /dev/null and b/artifacts/local-government-screens/04-detail-approved-votes.png differ
diff --git a/artifacts/local-government-screens/05-detail-full.png b/artifacts/local-government-screens/05-detail-full.png
new file mode 100644
index 00000000..4a64bb0d
Binary files /dev/null and b/artifacts/local-government-screens/05-detail-full.png differ
diff --git a/artifacts/local-government-screens/06-list-light.png b/artifacts/local-government-screens/06-list-light.png
new file mode 100644
index 00000000..c69bc393
Binary files /dev/null and b/artifacts/local-government-screens/06-list-light.png differ
diff --git a/artifacts/local-government-screens/07-detail-documents-participation.png b/artifacts/local-government-screens/07-detail-documents-participation.png
new file mode 100644
index 00000000..228a14d4
Binary files /dev/null and b/artifacts/local-government-screens/07-detail-documents-participation.png differ
diff --git a/artifacts/local-government-screens/08-detail-participation-provenance.png b/artifacts/local-government-screens/08-detail-participation-provenance.png
new file mode 100644
index 00000000..228a14d4
Binary files /dev/null and b/artifacts/local-government-screens/08-detail-participation-provenance.png differ
diff --git a/artifacts/local-government-screens/09-list-light.png b/artifacts/local-government-screens/09-list-light.png
new file mode 100644
index 00000000..c69bc393
Binary files /dev/null and b/artifacts/local-government-screens/09-list-light.png differ
diff --git a/artifacts/local-government-screens/10-list-error-state.png b/artifacts/local-government-screens/10-list-error-state.png
new file mode 100644
index 00000000..f1a1501b
Binary files /dev/null and b/artifacts/local-government-screens/10-list-error-state.png differ
diff --git a/packages/api/src/router/legistar.ts b/packages/api/src/router/legistar.ts
index 86281283..a13fcfd6 100644
--- a/packages/api/src/router/legistar.ts
+++ b/packages/api/src/router/legistar.ts
@@ -48,16 +48,14 @@ const listInput = z
query: z.string().trim().min(2).max(200).optional(),
limit: z.number().int().min(1).max(100).default(30),
offset: z.number().int().min(0).max(10_000).default(0),
- })
- .optional();
+ // Alias of `offset` so tRPC's tanstack infinite-query helpers can drive
+ // keyset-free paging from the client.
+ cursor: z.number().int().min(0).max(10_000).optional(),
+ });
async function listDecisions(input: z.infer) {
- const options = input ?? {
- jurisdiction: "sanjose" as const,
- timeline: "upcoming" as const,
- limit: 30,
- offset: 0,
- };
+ const options = input;
+ const effectiveOffset = options.cursor ?? options.offset;
const now = new Date();
const conditions: SQL[] = [
eq(LocalMeeting.jurisdictionKey, options.jurisdiction),
@@ -89,11 +87,15 @@ async function listDecisions(input: z.infer) {
when ${LocalDecision.scopeKind} = 'citywide' then 1
else 2
end`
- : sql`0`;
- const order =
- options.timeline === "recent"
- ? [asc(relevance), desc(LocalMeeting.startsAt)]
- : [asc(relevance), asc(LocalMeeting.startsAt)];
+ : null;
+ // A bare constant like `order by 0` is an ordinal reference in Postgres and
+ // errors out; the relevance term only exists when a district is provided.
+ const order = [
+ ...(relevance !== null ? [asc(relevance)] : []),
+ ...(options.timeline === "recent"
+ ? [desc(LocalMeeting.startsAt)]
+ : [asc(LocalMeeting.startsAt)]),
+ ];
return db
.select({
@@ -133,7 +135,7 @@ async function listDecisions(input: z.infer) {
.where(and(...conditions))
.orderBy(...order)
.limit(options.limit)
- .offset(options.offset);
+ .offset(effectiveOffset);
}
export const legistarRouter = {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c2c90c1d..41a8fa0d 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