Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 26 additions & 17 deletions apps/expo/src/components/UpcomingMeetingsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,16 @@ import {
import { FontAwesome } from "@expo/vector-icons";
import { useQuery } from "@tanstack/react-query";

import type { LegistarMeeting } from "@acme/api/integrations/legistar";
import type { RouterOutputs } from "@acme/api";

import { Text, View } from "~/components/Themed";
import { fontBody, fontEditorial, fontSize, rd, sp, useTheme } from "~/styles";
import { trpc } from "~/utils/api";

type Meeting = RouterOutputs["localGovernment"]["listMeetings"][number];

interface UpcomingMeetingsSectionProps {
onMeetingPress?: (
meeting: LegistarMeeting & { jurisdiction: string },
) => void;
onMeetingPress?: (meeting: Meeting) => void;
}

function formatDate(iso: string): string {
Expand All @@ -34,7 +34,7 @@ export function UpcomingMeetingsSection({
const { theme } = useTheme();

const meetingsQuery = useQuery(
trpc.legistar.getMeetings.queryOptions({ daysAhead: 30 }),
trpc.localGovernment.listMeetings.queryOptions({ daysAhead: 90 }),
);

return (
Expand All @@ -47,30 +47,37 @@ export function UpcomingMeetingsSection({

{meetingsQuery.data?.slice(0, 8).map((meeting, index) => (
<TouchableOpacity
key={`${meeting.EventId}-${index}`}
key={`${meeting.provider}-${meeting.sourceId}-${index}`}
style={[styles.card, { backgroundColor: theme.card }]}
onPress={() => onMeetingPress?.(meeting)}
onPress={() =>
onMeetingPress
? onMeetingPress(meeting)
: void Linking.openURL(meeting.sourceUrl)
}
activeOpacity={0.8}
>
<View style={styles.cardAccent} />
<View style={styles.cardContent}>
<View style={styles.meta}>
<Text style={styles.jurisdiction}>{meeting.jurisdiction}</Text>
<Text style={styles.date}>{formatDate(meeting.EventDate)}</Text>
<Text style={styles.date}>{formatDate(meeting.startsAt.toString())}</Text>
</View>
<Text style={styles.title} numberOfLines={2}>
{meeting.EventBodyName}
{meeting.isCancelled ? "Cancelled: " : ""}
{meeting.title}
</Text>
{meeting.EventLocation && (
{meeting.location && (
<Text style={styles.location} numberOfLines={1}>
{meeting.EventLocation}
{meeting.location}
</Text>
)}
<View style={styles.icons}>
{meeting.EventAgendaFile && (
{meeting.documents.find((document) => document.kind === "agenda") && (
<TouchableOpacity
onPress={() =>
void Linking.openURL(meeting.EventAgendaFile ?? "")
void Linking.openURL(
meeting.documents.find((document) => document.kind === "agenda")?.url ?? "",
)
}
hitSlop={8}
>
Expand All @@ -81,10 +88,10 @@ export function UpcomingMeetingsSection({
/>
</TouchableOpacity>
)}
{meeting.EventVideoPath && (
{meeting.videoUrl && (
<TouchableOpacity
onPress={() =>
void Linking.openURL(meeting.EventVideoPath ?? "")
void Linking.openURL(meeting.videoUrl ?? "")
}
hitSlop={8}
>
Expand All @@ -95,10 +102,12 @@ export function UpcomingMeetingsSection({
/>
</TouchableOpacity>
)}
{meeting.EventMinutesFile && (
{meeting.documents.find((document) => document.kind === "minutes") && (
<TouchableOpacity
onPress={() =>
void Linking.openURL(meeting.EventMinutesFile ?? "")
void Linking.openURL(
meeting.documents.find((document) => document.kind === "minutes")?.url ?? "",
)
}
hitSlop={8}
>
Expand Down
2 changes: 2 additions & 0 deletions apps/scraper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Only these five are registered and run by `all`:
| CLI name | Source and data fetched | Stored/used as |
| ------------------- | ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| `federalregister` | Federal Register API presidential documents, then each document's body HTML | `government_content`; AI article/summary and feed-image enrichment |
| `durham-bocc` | Durham County's official Legistar API (current election cycle only) | Provider-neutral meetings, agenda items, actions, votes, and official document links; no AI required |
| `congress` | Congress.gov API bill list, detail, CRS summaries, formatted text, and legislative actions | `bill`; powers federal bill content and AI/feed enrichment |
| `scotus` | CourtListener opinion clusters, dockets, and sub-opinion text for the Supreme Court | `court_case`; powers court content and AI/feed enrichment |
| `scc-cvig` | Hand-configured Santa Clara County voter-guide PDFs | Candidate statements in `CivicApiCache`; the API matches statements to candidates |
Expand Down Expand Up @@ -124,6 +125,7 @@ CONGRESS_MAX_ITEMS=10 pnpm --filter @acme/scraper run start congress
| `SCOTUS_MAX_ITEMS` | 50 | CourtListener opinion clusters |
| `SCC_CVIG_MAX_ITEMS` | 10 | Voter-guide PDF documents |
| `CA_SOS_MAX_ITEMS` | 9 | Statewide-office candidate-statement pages |
| `DURHAM_BOCC_MAX_ITEMS` | 100 | Current-cycle Durham County BOCC meetings |
| `SCRAPER_MAX_NEW_ITEMS_PER_RUN` | 10 | New records receiving expensive AI/image enrichment |

These are per-run limits, not durable calendar-day quotas. Schedule one run per
Expand Down
2 changes: 2 additions & 0 deletions apps/scraper/src/scraper-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ import type { ScraperEnvContract } from "@acme/env";
import { caSosStatementsConfig } from "./scrapers/ca-sos-statements.config.js";
import { congressConfig } from "./scrapers/congress.config.js";
import { federalregisterConfig } from "./scrapers/federalregister.config.js";
import { durhamBoccConfig } from "./scrapers/durham-bocc.config.js";
import { sccCvigConfig } from "./scrapers/scc-cvig.config.js";
import { scotusConfig } from "./scrapers/scotus.config.js";

export const scraperContracts: readonly ScraperEnvContract[] = [
federalregisterConfig,
durhamBoccConfig,
congressConfig,
scotusConfig,
sccCvigConfig,
Expand Down
2 changes: 2 additions & 0 deletions apps/scraper/src/scrapers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import type { Scraper } from "./utils/types.js";
import { caSosStatements } from "./scrapers/ca-sos-statements.js";
import { congress } from "./scrapers/congress.js";
import { federalregister } from "./scrapers/federalregister.js";
import { durhamBocc } from "./scrapers/durham-bocc.js";
import { sccCvig } from "./scrapers/scc-cvig.js";
import { scotus } from "./scrapers/scotus.js";

export const scrapers: readonly Scraper[] = [
federalregister,
durhamBocc,
congress,
scotus,
sccCvig,
Expand Down
11 changes: 11 additions & 0 deletions apps/scraper/src/scrapers/durham-bocc.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { ScraperEnvContract } from "@acme/env";

export const durhamBoccConfig = {
id: "durham-bocc",
name: "Durham County BOCC",
source: "Durham County Legistar Web API — BOCC meetings and actions",
environment: {
required: ["POSTGRES_URL"],
optional: ["DURHAM_BOCC_MAX_ITEMS"],
},
} as const satisfies ScraperEnvContract;
74 changes: 74 additions & 0 deletions apps/scraper/src/scrapers/durham-bocc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";

import {
adaptDurhamItem,
adaptDurhamMeeting,
adaptDurhamVote,
currentElectionCycleStart,
parseDurhamStart,
} from "./durham-bocc.js";

async function fixture(name: string): Promise<unknown> {
const url = new URL(`./fixtures/durham-bocc/${name}.json`, import.meta.url);
return JSON.parse(await readFile(url, "utf8")) as unknown;
}

void test("maps regular, work, cancelled, and amended meetings", async () => {
const events = (await fixture("events")) as unknown[];
const [regular, work, cancelled, amended] = events.map(adaptDurhamMeeting);

assert.equal(regular?.meetingType, "Regular Session");
assert.equal(regular?.isCancelled, false);
assert.match(regular?.videoUrl ?? "", /ID1=1539/);
assert.equal(work?.meetingType, "Work Session");
assert.equal(cancelled?.isCancelled, true);
assert.equal(cancelled?.status, "cancelled");
assert.equal(amended?.isAmended, true);
assert.equal(amended?.documents[0]?.title, "Amended Agenda");
});

void test("maps actions, consent status, official attachments, and named votes", async () => {
const [rawItem] = (await fixture("items")) as unknown[];
const [rawVote] = (await fixture("votes")) as unknown[];
const item = adaptDurhamItem(rawItem);
const vote = adaptDurhamVote(rawVote);

assert.equal(item.isConsent, true);
assert.equal(item.action, "Approved");
assert.equal(item.outcome, "Passed");
assert.equal(item.tally, "5-0");
assert.equal(item.documents.length, 2);
assert.equal(item.documents[1]?.language, "es");
assert.equal(vote.personName, "Commissioner A");
assert.equal(vote.value, "Aye");
});

void test("uses stable source ids and changes checksum when an amendment changes", async () => {
const [raw] = (await fixture("events")) as Record<string, unknown>[];
const original = adaptDurhamMeeting(raw);
const replaced = adaptDurhamMeeting({
...raw,
EventRowVersion: "replacement-v2",
EventLastModifiedUtc: "2026-01-13T00:00:00.000",
});

assert.equal(original.sourceId, replaced.sourceId);
assert.notEqual(original.contentHash, replaced.contentHash);
});

void test("parses Durham local time with DST and bounds to the current cycle", () => {
assert.equal(
parseDurhamStart("2026-01-12T00:00:00", "6:00 PM").toISOString(),
"2026-01-12T23:00:00.000Z",
);
assert.equal(
parseDurhamStart("2026-07-13T00:00:00", "5:00 PM").toISOString(),
"2026-07-13T21:00:00.000Z",
);
assert.equal(
currentElectionCycleStart(new Date("2026-07-21T00:00:00Z")).toISOString(),
"2025-01-01T00:00:00.000Z",
);
});
Loading
Loading