Skip to content

Commit 1e4b5c7

Browse files
authored
Fix Bill/Hearing Sync Logic (#2019)
* fix(hearings): Update bill/hearing sync so that bills are only synced with hearings from the same general court. This was not an issue previously because we only scraped hearings from the current general court, but we removed that filter to accommodate the backfill of the bill/hearing mappings and accidentally broke that logic. We now will fetch the court of the bills at the start, build a map to keep track of the bill/court linkage, and use that to filter down to only hearings in the same general court. Running this once will overwrite and correct the existing links * test(hearings): Extracting the bill/hearing matching logic to a helper function for testability, adding unit tests to cover the new logic
1 parent 90658ad commit 1e4b5c7

2 files changed

Lines changed: 268 additions & 69 deletions

File tree

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { Timestamp } from "../firebase"
2+
import { Hearing } from "../events/types"
3+
import { computeEventUpdates, EventMatchBill } from "./updateBillReferences"
4+
5+
/** Helper to create a minimal Hearing object for testing */
6+
function createHearing(
7+
id: string,
8+
startsAt: Timestamp,
9+
documents: Array<{ billNumber: string; courtNumber: number }>
10+
): Hearing {
11+
return {
12+
id,
13+
type: "hearing",
14+
startsAt,
15+
fetchedAt: Timestamp.fromMillis(Date.now()),
16+
content: {
17+
EventId: 1,
18+
EventDate: "2026-02-01T10:00:00",
19+
StartTime: "2026-02-01T10:00:00",
20+
Description: "Test hearing",
21+
Name: "Test Hearing",
22+
Status: "Scheduled",
23+
HearingHost: {
24+
CommitteeCode: "ABC",
25+
GeneralCourtNumber: 194
26+
},
27+
Location: {
28+
AddressLine1: null,
29+
AddressLine2: null,
30+
City: null,
31+
LocationName: "Room 1",
32+
State: null,
33+
ZipCode: null
34+
},
35+
HearingAgendas: [
36+
{
37+
DocumentsInAgenda: documents.map(doc => ({
38+
BillNumber: doc.billNumber,
39+
GeneralCourtNumber: doc.courtNumber,
40+
PrimarySponsor: null,
41+
Title: "Test Bill"
42+
})),
43+
StartTime: "2026-02-01T10:00:00",
44+
EndTime: "2026-02-01T11:00:00",
45+
Topic: "Test Topic"
46+
}
47+
],
48+
RescheduledHearing: null
49+
}
50+
}
51+
}
52+
53+
describe("computeEventUpdates", () => {
54+
const futureTime = Timestamp.fromMillis(Date.now() + 86400000) // 1 day in future
55+
const now = Timestamp.fromMillis(Date.now())
56+
57+
describe("court matching", () => {
58+
it("links a bill with an associated hearing when both are in the same court", () => {
59+
const bills: EventMatchBill[] = [{ id: "H100", court: 194 }]
60+
61+
const hearings: Hearing[] = [
62+
createHearing("hearing-1", futureTime, [
63+
{ billNumber: "H100", courtNumber: 194 }
64+
])
65+
]
66+
67+
const updates = computeEventUpdates(bills, hearings, now)
68+
69+
expect(updates.get("H100")).toBeDefined()
70+
expect(updates.get("H100")?.hearingIds).toContain("hearing-1")
71+
expect(updates.get("H100")?.nextHearingId).toBe("hearing-1")
72+
})
73+
74+
it("does not link a bill with an associated hearing when they are in different courts", () => {
75+
const bills: EventMatchBill[] = [{ id: "H100", court: 194 }]
76+
77+
const hearings: Hearing[] = [
78+
createHearing("hearing-1", futureTime, [
79+
{ billNumber: "H100", courtNumber: 193 } // Different court
80+
])
81+
]
82+
83+
const updates = computeEventUpdates(bills, hearings, now)
84+
85+
// Bill should not have any hearing updates since courts don't match
86+
expect(updates.get("H100")).toBeUndefined()
87+
})
88+
89+
it("does not link a bill with a hearing if the bill id is not found in the hearing's agenda", () => {
90+
const bills: EventMatchBill[] = [{ id: "H100", court: 194 }]
91+
92+
const hearings: Hearing[] = [
93+
createHearing("hearing-1", futureTime, [
94+
{ billNumber: "H200", courtNumber: 194 } // Different bill
95+
])
96+
]
97+
98+
const updates = computeEventUpdates(bills, hearings, now)
99+
100+
// H100 should not have any hearing updates
101+
expect(updates.get("H100")).toBeUndefined()
102+
// H200 is in the hearing but not in our bills list, so no court match possible
103+
expect(updates.get("H200")).toBeUndefined()
104+
})
105+
})
106+
107+
describe("multiple hearings and bills", () => {
108+
it("correctly matches multiple bills with different courts to their respective hearings", () => {
109+
const bills: EventMatchBill[] = [
110+
{ id: "H100", court: 194 },
111+
{ id: "H101", court: 193 }
112+
]
113+
114+
const hearings: Hearing[] = [
115+
createHearing("hearing-2", futureTime, [
116+
{ billNumber: "H100", courtNumber: 194 }
117+
]),
118+
createHearing("hearing-1", futureTime, [
119+
{ billNumber: "H101", courtNumber: 193 }
120+
])
121+
]
122+
123+
const updates = computeEventUpdates(bills, hearings, now)
124+
125+
expect(updates.get("H100")?.hearingIds).toContain("hearing-2")
126+
expect(updates.get("H100")?.hearingIds).not.toContain("hearing-1")
127+
128+
expect(updates.get("H101")?.hearingIds).toContain("hearing-1")
129+
expect(updates.get("H101")?.hearingIds).not.toContain("hearing-2")
130+
})
131+
132+
it("only matches bills to hearings with matching court, even when bill appears in multiple hearings", () => {
133+
const bills: EventMatchBill[] = [{ id: "H100", court: 194 }]
134+
135+
const hearings: Hearing[] = [
136+
createHearing("hearing-correct-court", futureTime, [
137+
{ billNumber: "H100", courtNumber: 194 }
138+
]),
139+
createHearing("hearing-wrong-court", futureTime, [
140+
{ billNumber: "H100", courtNumber: 193 }
141+
])
142+
]
143+
144+
const updates = computeEventUpdates(bills, hearings, now)
145+
146+
expect(updates.get("H100")?.hearingIds).toContain("hearing-correct-court")
147+
expect(updates.get("H100")?.hearingIds).not.toContain(
148+
"hearing-wrong-court"
149+
)
150+
expect(updates.get("H100")?.hearingIds).toHaveLength(1)
151+
})
152+
})
153+
154+
describe("bills without court field", () => {
155+
it("does not link a bill without a court field to any hearing", () => {
156+
const bills: EventMatchBill[] = [{ id: "H100" }] // No court field
157+
158+
const hearings: Hearing[] = [
159+
createHearing("hearing-1", futureTime, [
160+
{ billNumber: "H100", courtNumber: 194 }
161+
])
162+
]
163+
164+
const updates = computeEventUpdates(bills, hearings, now)
165+
166+
expect(updates.get("H100")).toBeUndefined()
167+
})
168+
})
169+
})

functions/src/bills/updateBillReferences.ts

Lines changed: 99 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,103 @@ import { db, FieldValue, Timestamp } from "../firebase"
44
import { Member, MemberReference } from "../members/types"
55
import BillProcessor, { BillUpdates } from "./BillProcessor"
66

7+
/** Input bill for event matching */
8+
export type EventMatchBill = {
9+
id: string
10+
court?: number
11+
nextHearingId?: string
12+
}
13+
14+
/** Computes event updates for bills based on hearing data */
15+
export function computeEventUpdates(
16+
bills: EventMatchBill[],
17+
hearings: Hearing[],
18+
now: Timestamp
19+
): BillUpdates {
20+
const updates: BillUpdates = new Map()
21+
22+
// Build a map of billId -> court for matching
23+
const billCourtMap = new Map<string, number>()
24+
bills.forEach(bill => {
25+
if (bill.id && bill.court !== undefined) {
26+
billCourtMap.set(bill.id, bill.court)
27+
}
28+
})
29+
30+
// Build mapping from billId -> hearingIds and compute earliest upcoming hearing
31+
const hearingIdsByBill = new Map<string, Set<string>>()
32+
33+
hearings.forEach(hearing => {
34+
const hearingId = hearing.id
35+
const startsAt = hearing.startsAt
36+
37+
hearing.content.HearingAgendas.forEach(agenda => {
38+
agenda.DocumentsInAgenda.forEach(doc => {
39+
const billId = doc.BillNumber
40+
const docCourtNumber = doc.GeneralCourtNumber
41+
42+
// Only match hearings with bills from the same general court
43+
const billCourt = billCourtMap.get(billId)
44+
if (billCourt === undefined || billCourt !== docCourtNumber) {
45+
return
46+
}
47+
48+
if (!hearingIdsByBill.has(billId))
49+
hearingIdsByBill.set(billId, new Set())
50+
hearingIdsByBill.get(billId)!.add(hearingId)
51+
52+
// Track next upcoming hearing per bill (startsAt in the future)
53+
if (startsAt.toMillis() >= now.toMillis()) {
54+
const existing = updates.get(billId)
55+
if (
56+
!existing ||
57+
(existing.nextHearingAt as Timestamp | undefined)?.toMillis?.()! >
58+
startsAt.toMillis()
59+
) {
60+
updates.set(billId, {
61+
nextHearingAt: startsAt,
62+
nextHearingId: hearingId
63+
})
64+
}
65+
}
66+
})
67+
})
68+
})
69+
70+
hearingIdsByBill.forEach((ids, billId) => {
71+
const existing = updates.get(billId) ?? {}
72+
updates.set(billId, {
73+
...existing,
74+
hearingIds: Array.from(ids)
75+
})
76+
})
77+
78+
// Remove the next hearing on any bills that previously had an upcoming hearing
79+
// but are no longer on any upcoming hearing agendas.
80+
const upcomingHearingBillIds = new Set<string>()
81+
updates.forEach((u, id) => {
82+
if ((u.nextHearingAt as Timestamp | undefined)?.toMillis?.())
83+
upcomingHearingBillIds.add(id)
84+
})
85+
const existingBillsWithEvents = bills
86+
.filter(b => !!b.nextHearingId)
87+
.map(b => b.id as string)
88+
const billsWithRemovedEvents = difference(
89+
existingBillsWithEvents,
90+
Array.from(upcomingHearingBillIds)
91+
)
92+
billsWithRemovedEvents.forEach(id => {
93+
const existing = updates.get(id) ?? {}
94+
updates.set(id, {
95+
...existing,
96+
nextHearingAt: FieldValue.delete(),
97+
nextHearingId: FieldValue.delete()
98+
})
99+
})
100+
101+
return updates
102+
}
103+
7104
/**
8105
* Updates references to other entities for each bill.
9106
*
@@ -32,7 +129,7 @@ class UpdateBillReferences extends BillProcessor {
32129
}
33130

34131
override get billFields() {
35-
return ["id", "nextHearingId"]
132+
return ["id", "court", "nextHearingId"]
36133
}
37134

38135
getCityUpdates(): BillUpdates {
@@ -94,75 +191,8 @@ class UpdateBillReferences extends BillProcessor {
94191
.where("type", "==", "hearing")
95192
.get()
96193
.then(this.load(Hearing))
97-
const updates: BillUpdates = new Map()
98-
99-
// Build mapping from billId -> hearingIds and compute earliest upcoming hearing
100-
const hearingIdsByBill = new Map<string, Set<string>>()
101-
102194
const now = Timestamp.fromMillis(Date.now())
103-
104-
hearings.forEach(hearing => {
105-
const hearingId = hearing.id
106-
const startsAt = hearing.startsAt
107-
108-
hearing.content.HearingAgendas.forEach(agenda => {
109-
agenda.DocumentsInAgenda.forEach(doc => {
110-
const billId = doc.BillNumber
111-
112-
if (!hearingIdsByBill.has(billId))
113-
hearingIdsByBill.set(billId, new Set())
114-
hearingIdsByBill.get(billId)!.add(hearingId)
115-
116-
// Track next upcoming hearing per bill (startsAt in the future)
117-
if (startsAt.toMillis() >= now.toMillis()) {
118-
const existing = updates.get(billId)
119-
if (
120-
!existing ||
121-
(existing.nextHearingAt as Timestamp | undefined)?.toMillis?.()! >
122-
startsAt.toMillis()
123-
) {
124-
updates.set(billId, {
125-
nextHearingAt: startsAt,
126-
nextHearingId: hearingId
127-
})
128-
}
129-
}
130-
})
131-
})
132-
})
133-
134-
hearingIdsByBill.forEach((ids, billId) => {
135-
const existing = updates.get(billId) ?? {}
136-
updates.set(billId, {
137-
...existing,
138-
hearingIds: Array.from(ids)
139-
})
140-
})
141-
142-
// Remove the next hearing on any bills that previously had an upcoming hearing
143-
// but are no longer on any upcoming hearing agendas.
144-
const upcomingHearingBillIds = new Set<string>()
145-
updates.forEach((u, id) => {
146-
if ((u.nextHearingAt as Timestamp | undefined)?.toMillis?.())
147-
upcomingHearingBillIds.add(id)
148-
})
149-
const existingBillsWithEvents = this.bills
150-
.filter(b => !!b.nextHearingId)
151-
.map(b => b.id as string)
152-
const billsWithRemovedEvents = difference(
153-
existingBillsWithEvents,
154-
Array.from(upcomingHearingBillIds)
155-
)
156-
billsWithRemovedEvents.forEach(id => {
157-
const existing = updates.get(id) ?? {}
158-
updates.set(id, {
159-
...existing,
160-
nextHearingAt: FieldValue.delete(),
161-
nextHearingId: FieldValue.delete()
162-
})
163-
})
164-
165-
return updates
195+
return computeEventUpdates(this.bills, hearings, now)
166196
}
167197

168198
formatChair(m: MemberReference | null) {

0 commit comments

Comments
 (0)