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