Skip to content
Merged
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
169 changes: 169 additions & 0 deletions functions/src/bills/updateBillReferences.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { Timestamp } from "../firebase"
import { Hearing } from "../events/types"
import { computeEventUpdates, EventMatchBill } from "./updateBillReferences"

/** Helper to create a minimal Hearing object for testing */
function createHearing(
id: string,
startsAt: Timestamp,
documents: Array<{ billNumber: string; courtNumber: number }>
): Hearing {
return {
id,
type: "hearing",
startsAt,
fetchedAt: Timestamp.fromMillis(Date.now()),
content: {
EventId: 1,
EventDate: "2026-02-01T10:00:00",
StartTime: "2026-02-01T10:00:00",
Description: "Test hearing",
Name: "Test Hearing",
Status: "Scheduled",
HearingHost: {
CommitteeCode: "ABC",
GeneralCourtNumber: 194
},
Location: {
AddressLine1: null,
AddressLine2: null,
City: null,
LocationName: "Room 1",
State: null,
ZipCode: null
},
HearingAgendas: [
{
DocumentsInAgenda: documents.map(doc => ({
BillNumber: doc.billNumber,
GeneralCourtNumber: doc.courtNumber,
PrimarySponsor: null,
Title: "Test Bill"
})),
StartTime: "2026-02-01T10:00:00",
EndTime: "2026-02-01T11:00:00",
Topic: "Test Topic"
}
],
RescheduledHearing: null
}
}
}

describe("computeEventUpdates", () => {
const futureTime = Timestamp.fromMillis(Date.now() + 86400000) // 1 day in future
const now = Timestamp.fromMillis(Date.now())

describe("court matching", () => {
it("links a bill with an associated hearing when both are in the same court", () => {
const bills: EventMatchBill[] = [{ id: "H100", court: 194 }]

const hearings: Hearing[] = [
createHearing("hearing-1", futureTime, [
{ billNumber: "H100", courtNumber: 194 }
])
]

const updates = computeEventUpdates(bills, hearings, now)

expect(updates.get("H100")).toBeDefined()
expect(updates.get("H100")?.hearingIds).toContain("hearing-1")
expect(updates.get("H100")?.nextHearingId).toBe("hearing-1")
})

it("does not link a bill with an associated hearing when they are in different courts", () => {
const bills: EventMatchBill[] = [{ id: "H100", court: 194 }]

const hearings: Hearing[] = [
createHearing("hearing-1", futureTime, [
{ billNumber: "H100", courtNumber: 193 } // Different court
])
]

const updates = computeEventUpdates(bills, hearings, now)

// Bill should not have any hearing updates since courts don't match
expect(updates.get("H100")).toBeUndefined()
})

it("does not link a bill with a hearing if the bill id is not found in the hearing's agenda", () => {
const bills: EventMatchBill[] = [{ id: "H100", court: 194 }]

const hearings: Hearing[] = [
createHearing("hearing-1", futureTime, [
{ billNumber: "H200", courtNumber: 194 } // Different bill
])
]

const updates = computeEventUpdates(bills, hearings, now)

// H100 should not have any hearing updates
expect(updates.get("H100")).toBeUndefined()
// H200 is in the hearing but not in our bills list, so no court match possible
expect(updates.get("H200")).toBeUndefined()
})
})

describe("multiple hearings and bills", () => {
it("correctly matches multiple bills with different courts to their respective hearings", () => {
const bills: EventMatchBill[] = [
{ id: "H100", court: 194 },
{ id: "H101", court: 193 }
]

const hearings: Hearing[] = [
createHearing("hearing-2", futureTime, [
{ billNumber: "H100", courtNumber: 194 }
]),
createHearing("hearing-1", futureTime, [
{ billNumber: "H101", courtNumber: 193 }
])
]

const updates = computeEventUpdates(bills, hearings, now)

expect(updates.get("H100")?.hearingIds).toContain("hearing-2")
expect(updates.get("H100")?.hearingIds).not.toContain("hearing-1")

expect(updates.get("H101")?.hearingIds).toContain("hearing-1")
expect(updates.get("H101")?.hearingIds).not.toContain("hearing-2")
})

it("only matches bills to hearings with matching court, even when bill appears in multiple hearings", () => {
const bills: EventMatchBill[] = [{ id: "H100", court: 194 }]

const hearings: Hearing[] = [
createHearing("hearing-correct-court", futureTime, [
{ billNumber: "H100", courtNumber: 194 }
]),
createHearing("hearing-wrong-court", futureTime, [
{ billNumber: "H100", courtNumber: 193 }
])
]

const updates = computeEventUpdates(bills, hearings, now)

expect(updates.get("H100")?.hearingIds).toContain("hearing-correct-court")
expect(updates.get("H100")?.hearingIds).not.toContain(
"hearing-wrong-court"
)
expect(updates.get("H100")?.hearingIds).toHaveLength(1)
})
})

describe("bills without court field", () => {
it("does not link a bill without a court field to any hearing", () => {
const bills: EventMatchBill[] = [{ id: "H100" }] // No court field

const hearings: Hearing[] = [
createHearing("hearing-1", futureTime, [
{ billNumber: "H100", courtNumber: 194 }
])
]

const updates = computeEventUpdates(bills, hearings, now)

expect(updates.get("H100")).toBeUndefined()
})
})
})
168 changes: 99 additions & 69 deletions functions/src/bills/updateBillReferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,103 @@ import { db, FieldValue, Timestamp } from "../firebase"
import { Member, MemberReference } from "../members/types"
import BillProcessor, { BillUpdates } from "./BillProcessor"

/** Input bill for event matching */
export type EventMatchBill = {
id: string
court?: number
nextHearingId?: string
}

/** Computes event updates for bills based on hearing data */
export function computeEventUpdates(
bills: EventMatchBill[],
hearings: Hearing[],
now: Timestamp
): BillUpdates {
const updates: BillUpdates = new Map()

// Build a map of billId -> court for matching
const billCourtMap = new Map<string, number>()
bills.forEach(bill => {
if (bill.id && bill.court !== undefined) {
billCourtMap.set(bill.id, bill.court)
}
})

// Build mapping from billId -> hearingIds and compute earliest upcoming hearing
const hearingIdsByBill = new Map<string, Set<string>>()

hearings.forEach(hearing => {
const hearingId = hearing.id
const startsAt = hearing.startsAt

hearing.content.HearingAgendas.forEach(agenda => {
agenda.DocumentsInAgenda.forEach(doc => {
const billId = doc.BillNumber
const docCourtNumber = doc.GeneralCourtNumber

// Only match hearings with bills from the same general court
const billCourt = billCourtMap.get(billId)
if (billCourt === undefined || billCourt !== docCourtNumber) {
return
}

if (!hearingIdsByBill.has(billId))
hearingIdsByBill.set(billId, new Set())
hearingIdsByBill.get(billId)!.add(hearingId)

// Track next upcoming hearing per bill (startsAt in the future)
if (startsAt.toMillis() >= now.toMillis()) {
const existing = updates.get(billId)
if (
!existing ||
(existing.nextHearingAt as Timestamp | undefined)?.toMillis?.()! >
startsAt.toMillis()
) {
updates.set(billId, {
nextHearingAt: startsAt,
nextHearingId: hearingId
})
}
}
})
})
})

hearingIdsByBill.forEach((ids, billId) => {
const existing = updates.get(billId) ?? {}
updates.set(billId, {
...existing,
hearingIds: Array.from(ids)
})
})

// Remove the next hearing on any bills that previously had an upcoming hearing
// but are no longer on any upcoming hearing agendas.
const upcomingHearingBillIds = new Set<string>()
updates.forEach((u, id) => {
if ((u.nextHearingAt as Timestamp | undefined)?.toMillis?.())
upcomingHearingBillIds.add(id)
})
const existingBillsWithEvents = bills
.filter(b => !!b.nextHearingId)
.map(b => b.id as string)
const billsWithRemovedEvents = difference(
existingBillsWithEvents,
Array.from(upcomingHearingBillIds)
)
billsWithRemovedEvents.forEach(id => {
const existing = updates.get(id) ?? {}
updates.set(id, {
...existing,
nextHearingAt: FieldValue.delete(),
nextHearingId: FieldValue.delete()
})
})

return updates
}

/**
* Updates references to other entities for each bill.
*
Expand Down Expand Up @@ -32,7 +129,7 @@ class UpdateBillReferences extends BillProcessor {
}

override get billFields() {
return ["id", "nextHearingId"]
return ["id", "court", "nextHearingId"]
}

getCityUpdates(): BillUpdates {
Expand Down Expand Up @@ -94,75 +191,8 @@ class UpdateBillReferences extends BillProcessor {
.where("type", "==", "hearing")
.get()
.then(this.load(Hearing))
const updates: BillUpdates = new Map()

// Build mapping from billId -> hearingIds and compute earliest upcoming hearing
const hearingIdsByBill = new Map<string, Set<string>>()

const now = Timestamp.fromMillis(Date.now())

hearings.forEach(hearing => {
const hearingId = hearing.id
const startsAt = hearing.startsAt

hearing.content.HearingAgendas.forEach(agenda => {
agenda.DocumentsInAgenda.forEach(doc => {
const billId = doc.BillNumber

if (!hearingIdsByBill.has(billId))
hearingIdsByBill.set(billId, new Set())
hearingIdsByBill.get(billId)!.add(hearingId)

// Track next upcoming hearing per bill (startsAt in the future)
if (startsAt.toMillis() >= now.toMillis()) {
const existing = updates.get(billId)
if (
!existing ||
(existing.nextHearingAt as Timestamp | undefined)?.toMillis?.()! >
startsAt.toMillis()
) {
updates.set(billId, {
nextHearingAt: startsAt,
nextHearingId: hearingId
})
}
}
})
})
})

hearingIdsByBill.forEach((ids, billId) => {
const existing = updates.get(billId) ?? {}
updates.set(billId, {
...existing,
hearingIds: Array.from(ids)
})
})

// Remove the next hearing on any bills that previously had an upcoming hearing
// but are no longer on any upcoming hearing agendas.
const upcomingHearingBillIds = new Set<string>()
updates.forEach((u, id) => {
if ((u.nextHearingAt as Timestamp | undefined)?.toMillis?.())
upcomingHearingBillIds.add(id)
})
const existingBillsWithEvents = this.bills
.filter(b => !!b.nextHearingId)
.map(b => b.id as string)
const billsWithRemovedEvents = difference(
existingBillsWithEvents,
Array.from(upcomingHearingBillIds)
)
billsWithRemovedEvents.forEach(id => {
const existing = updates.get(id) ?? {}
updates.set(id, {
...existing,
nextHearingAt: FieldValue.delete(),
nextHearingId: FieldValue.delete()
})
})

return updates
return computeEventUpdates(this.bills, hearings, now)
}

formatChair(m: MemberReference | null) {
Expand Down