diff --git a/components/NavbarComponents.tsx b/components/NavbarComponents.tsx
index 20ca856e2..6ef946065 100644
--- a/components/NavbarComponents.tsx
+++ b/components/NavbarComponents.tsx
@@ -142,6 +142,27 @@ export const NavbarLinkBills: React.FC<
)
}
+export const NavbarLinkLobbying: React.FC<
+ React.PropsWithChildren<{
+ handleClick?: any
+ other?: any
+ }>
+> = ({ handleClick, other }) => {
+ const isMobile = useMediaQuery("(max-width: 768px)")
+ const { t } = useTranslation(["common", "auth"])
+ return (
+
+
+ {t("navigation.lobbying")}
+
+
+ )
+}
+
export const NavbarLinkBallotQuestions: React.FC<
React.PropsWithChildren<{
handleClick?: any
diff --git a/components/bill/BillDetails.tsx b/components/bill/BillDetails.tsx
index d4d8a363a..65d941b49 100644
--- a/components/bill/BillDetails.tsx
+++ b/components/bill/BillDetails.tsx
@@ -6,7 +6,7 @@ import { Banner } from "../shared/StyledSharedComponents"
import { BillNumber, Styled } from "./BillNumber"
import { BillTestimonies } from "./BillTestimonies"
import BillTrackerConnectedView from "./BillTracker"
-import { LobbyingTable } from "./LobbyingTable"
+import { LobbyingBillCard } from "components/lobbying/LobbyingBillCard"
import { Committees, Hearing, Sponsors } from "./SponsorsAndCommittees"
import { Status } from "./Status"
import { Summary } from "./Summary"
@@ -99,7 +99,11 @@ export const BillDetails = ({ bill }: BillProps) => {
{flags.lobbyingTable && (
-
+
)}
diff --git a/components/db/lobbying.ts b/components/db/lobbying.ts
new file mode 100644
index 000000000..fabae0159
--- /dev/null
+++ b/components/db/lobbying.ts
@@ -0,0 +1,318 @@
+import {
+ collection,
+ doc,
+ getDoc,
+ getDocs,
+ limit,
+ orderBy,
+ query,
+ where
+} from "firebase/firestore"
+import { useAsync } from "react-async-hook"
+import type {
+ LobbyingClientSummary,
+ LobbyingFiling,
+ LobbyingRegistrant,
+ LobbyingStats
+} from "functions/src/lobbying/types"
+import { firestore } from "../firebase"
+
+// Mirror of constants in functions/src/lobbying/types.ts β kept here to avoid
+// pulling firebase-admin (a Node-only package) into the browser bundle.
+const FILINGS_COLLECTION = "lobbyingFilings"
+const REGISTRANTS_COLLECTION = "lobbyingRegistrants"
+const CLIENTS_COLLECTION = "lobbyingClients"
+const LOBBYING_STATS_COLLECTION = "lobbyingMeta"
+const LOBBYING_STATS_DOC_ID = "stats"
+
+// ββ Internal fetchers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+async function fetchLobbyingStats(): Promise
{
+ const snap = await getDoc(
+ doc(firestore, LOBBYING_STATS_COLLECTION, LOBBYING_STATS_DOC_ID)
+ )
+ return snap.exists() ? (snap.data() as LobbyingStats) : undefined
+}
+
+async function fetchFilingsForBill(
+ court: number,
+ billId: string
+): Promise {
+ const snap = await getDocs(
+ query(
+ collection(firestore, FILINGS_COLLECTION),
+ where("generalCourt", "==", court),
+ where("billId", "==", billId)
+ )
+ )
+ return snap.docs.map(d => d.data() as LobbyingFiling)
+}
+
+async function fetchRegistrants(opts: {
+ regType?: "Lobbyist" | "Employer"
+ year?: number
+ pageSize?: number
+}): Promise {
+ const constraints = [
+ opts.regType ? where("regType", "==", opts.regType) : undefined,
+ opts.year ? where("year", "==", opts.year) : undefined,
+ orderBy("entityNameNorm"),
+ limit(opts.pageSize ?? 50)
+ ].filter(Boolean) as Parameters[1][]
+
+ const snap = await getDocs(
+ query(collection(firestore, REGISTRANTS_COLLECTION), ...constraints)
+ )
+ return snap.docs.map(d => d.data() as LobbyingRegistrant)
+}
+
+async function fetchRegistrant(
+ registrantId: string
+): Promise {
+ const snap = await getDoc(
+ doc(firestore, REGISTRANTS_COLLECTION, registrantId)
+ )
+ return snap.exists() ? (snap.data() as LobbyingRegistrant) : undefined
+}
+
+async function fetchFilingsForRegistrant(
+ registrantId: string
+): Promise {
+ const snap = await getDocs(
+ query(
+ collection(firestore, FILINGS_COLLECTION),
+ where("registrantId", "==", registrantId),
+ orderBy("year", "desc")
+ )
+ )
+ return snap.docs.map(d => d.data() as LobbyingFiling)
+}
+
+async function fetchClients(opts: {
+ year?: number
+ pageSize?: number
+}): Promise {
+ const constraints = [
+ opts.year ? where("years", "array-contains", opts.year) : undefined,
+ orderBy("clientNameNorm"),
+ limit(opts.pageSize ?? 50)
+ ].filter(Boolean) as Parameters[1][]
+
+ const snap = await getDocs(
+ query(collection(firestore, CLIENTS_COLLECTION), ...constraints)
+ )
+ return snap.docs.map(d => d.data() as LobbyingClientSummary)
+}
+
+async function fetchClient(
+ clientSlug: string
+): Promise {
+ const snap = await getDoc(doc(firestore, CLIENTS_COLLECTION, clientSlug))
+ return snap.exists() ? (snap.data() as LobbyingClientSummary) : undefined
+}
+
+async function fetchAllRegistrants(): Promise {
+ const snap = await getDocs(
+ query(
+ collection(firestore, REGISTRANTS_COLLECTION),
+ orderBy("entityNameNorm"),
+ limit(2000)
+ )
+ )
+ return snap.docs.map(d => d.data() as LobbyingRegistrant)
+}
+
+async function fetchRegistrantsByEntityName(
+ entityNameNorm: string
+): Promise {
+ const snap = await getDocs(
+ query(
+ collection(firestore, REGISTRANTS_COLLECTION),
+ where("entityNameNorm", "==", entityNameNorm),
+ orderBy("year", "desc")
+ )
+ )
+ return snap.docs.map(d => d.data() as LobbyingRegistrant)
+}
+
+async function fetchFilingsForEntityName(
+ entityNameNorm: string
+): Promise {
+ const snap = await getDocs(
+ query(
+ collection(firestore, FILINGS_COLLECTION),
+ where("entityNameNorm", "==", entityNameNorm),
+ orderBy("year", "desc")
+ )
+ )
+ return snap.docs.map(d => d.data() as LobbyingFiling)
+}
+
+async function fetchFilingsForCourt(court: number): Promise {
+ const snap = await getDocs(
+ query(
+ collection(firestore, FILINGS_COLLECTION),
+ where("generalCourt", "==", court)
+ )
+ )
+ return snap.docs.map(d => d.data() as LobbyingFiling)
+}
+
+async function fetchFilingsForClient(
+ clientNameNorm: string
+): Promise {
+ const snap = await getDocs(
+ query(
+ collection(firestore, FILINGS_COLLECTION),
+ where("clientNameNorm", "==", clientNameNorm),
+ orderBy("year", "desc")
+ )
+ )
+ return snap.docs.map(d => d.data() as LobbyingFiling)
+}
+
+// ββ Public hooks ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+export function useLobbyingFilingsForCourt(court: number) {
+ return useAsync(fetchFilingsForCourt, [court])
+}
+
+export function useLobbyingAllRegistrants() {
+ return useAsync(fetchAllRegistrants, [])
+}
+
+export function useLobbyingRegistrantsByEntityName(entityNameNorm: string) {
+ return useAsync(fetchRegistrantsByEntityName, [entityNameNorm])
+}
+
+export function useLobbyingFilingsForEntityName(entityNameNorm: string) {
+ return useAsync(fetchFilingsForEntityName, [entityNameNorm])
+}
+
+export function useLobbyingStats() {
+ return useAsync(fetchLobbyingStats, [])
+}
+
+export function useLobbyingFilingsForBill(court: number, billId: string) {
+ return useAsync(fetchFilingsForBill, [court, billId])
+}
+
+export function useLobbyingRegistrants(opts: {
+ regType?: "Lobbyist" | "Employer"
+ year?: number
+ pageSize?: number
+}) {
+ return useAsync(fetchRegistrants, [opts])
+}
+
+export function useLobbyingRegistrant(registrantId: string) {
+ return useAsync(fetchRegistrant, [registrantId])
+}
+
+export function useLobbyingFilingsForRegistrant(registrantId: string) {
+ return useAsync(fetchFilingsForRegistrant, [registrantId])
+}
+
+export function useLobbyingClients(
+ opts: { year?: number; pageSize?: number } = {}
+) {
+ return useAsync(fetchClients, [opts])
+}
+
+export function useLobbyingClient(clientSlug: string) {
+ return useAsync(fetchClient, [clientSlug])
+}
+
+export function useLobbyingFilingsForClient(clientNameNorm: string) {
+ return useAsync(fetchFilingsForClient, [clientNameNorm])
+}
+
+async function fetchEntityFilingCounts(): Promise> {
+ const snap = await getDoc(
+ doc(firestore, LOBBYING_STATS_COLLECTION, "entityFilingCounts")
+ )
+ return snap.exists() ? (snap.data() as Record) : {}
+}
+
+async function fetchClientFilingCounts(): Promise> {
+ const snap = await getDoc(
+ doc(firestore, LOBBYING_STATS_COLLECTION, "clientFilingCounts")
+ )
+ return snap.exists() ? (snap.data() as Record) : {}
+}
+
+export function useLobbyingEntityFilingCounts() {
+ return useAsync(fetchEntityFilingCounts, [])
+}
+
+export function useLobbyingClientFilingCounts() {
+ return useAsync(fetchClientFilingCounts, [])
+}
+
+export type BillSummaryEntry = {
+ total: number
+ support: number
+ oppose: number
+ neutral: number
+ none: number
+ title?: string
+ clients?: number
+ lobbyists?: number
+}
+
+export type BillRow = {
+ billId: string
+ court: number
+ total: number
+ support: number
+ oppose: number
+ neutral: number
+ none: number
+ title: string
+ clients: number
+ lobbyists: number
+}
+
+async function fetchLobbyingBillSummaries(
+ court: number
+): Promise> {
+ const snap = await getDoc(
+ doc(firestore, LOBBYING_STATS_COLLECTION, `billSummaries_${court}`)
+ )
+ if (!snap.exists()) return {}
+ const raw = snap.data() as { data?: string }
+ if (!raw.data) return {}
+ return JSON.parse(raw.data) as Record
+}
+
+export function useLobbyingBillSummaries(court: number) {
+ return useAsync(fetchLobbyingBillSummaries, [court])
+}
+
+export function useLobbyingBillRows(courts: number[]) {
+ const courtsKey = courts.join(",")
+ return useAsync(
+ async (key: string) => {
+ if (!key) return [] as BillRow[]
+ const courtList = key.split(",").map(Number)
+ const results = await Promise.all(
+ courtList.map(fetchLobbyingBillSummaries)
+ )
+ return courtList.flatMap((court, i) =>
+ Object.entries(results[i]).map(([billId, e]) => ({
+ billId,
+ court,
+ total: e.total,
+ support: e.support,
+ oppose: e.oppose,
+ neutral: e.neutral,
+ none: e.none,
+ title: e.title ?? "",
+ clients: e.clients ?? 0,
+ lobbyists: e.lobbyists ?? 0
+ }))
+ )
+ },
+ [courtsKey]
+ )
+}
diff --git a/components/featureFlags.ts b/components/featureFlags.ts
index 83041ebcd..b3afb0ba9 100644
--- a/components/featureFlags.ts
+++ b/components/featureFlags.ts
@@ -37,7 +37,7 @@ const defaults: Record = {
notifications: true,
billTracker: true,
followOrg: true,
- lobbyingTable: false,
+ lobbyingTable: true,
hearingsAndTranscriptions: true,
phoneVerificationUI: true,
ballotQuestions: true,
diff --git a/components/hearing/HearingSidebar.tsx b/components/hearing/HearingSidebar.tsx
index 6bedeb78e..7978b5a13 100644
--- a/components/hearing/HearingSidebar.tsx
+++ b/components/hearing/HearingSidebar.tsx
@@ -4,12 +4,18 @@ import Link from "next/link"
import { useCallback, useEffect, useState } from "react"
import type { ModalProps } from "react-bootstrap"
import styled from "styled-components"
-import { Col, Image, Modal, Row } from "../bootstrap"
+import { Col, Image, Modal, Row, Dropdown } from "../bootstrap"
import { firestore } from "../firebase"
import * as links from "../links"
import { billSiteURL, Internal } from "../links"
import { LabeledIcon } from "../shared"
-import { Paragraph, TranscriptData, formatVTTTimestamp } from "./hearing"
+import {
+ CommitteeRecommendation,
+ CommitteeVote,
+ CommitteeVoteRecord,
+ LegislativeMemberSummary,
+ TranscriptData
+} from "./hearing"
type Bill = {
BillNumber: string
@@ -365,9 +371,9 @@ function AgendaBill({
const { t } = useTranslation(["common", "hearing"])
const BillNumber = element.BillNumber
const CourtNumber = element.GeneralCourtNumber
- const [committeeRecommendations, setCommitteeRecommendations] = useState(
- []
- )
+ const [committeeActions, setCommitteeActions] = useState<
+ CommitteeRecommendation[]
+ >([])
const [settingsModal, setSettingsModal] = useState<"show" | null>(null)
const close = () => setSettingsModal(null)
@@ -378,21 +384,36 @@ function AgendaBill({
)
const docData = bill.data()
- setCommitteeRecommendations(docData?.content.CommitteeRecommendations)
+ // All recommendations for this bill
+ let committeeRecommendations: CommitteeRecommendation[] =
+ docData?.content.CommitteeRecommendations
+ for (const action of committeeRecommendations) {
+ action.Votes = action.Votes?.filter(
+ vote =>
+ vote.Vote &&
+ vote.Vote.length &&
+ vote.Vote.some(
+ vote =>
+ vote.Adverse?.length ||
+ vote.Favorable?.length ||
+ vote.NoVoteRecorded?.length ||
+ vote.ReserveRight?.length
+ )
+ )
+ }
+ // Recommendations for this bill from the relevant committee (regardless of whether they are in this hearing specifically)
+ committeeRecommendations = committeeRecommendations.filter(
+ action =>
+ action.Committee?.CommitteeCode === committeeCode &&
+ action.Votes?.length
+ )
+ setCommitteeActions(committeeRecommendations)
}, [BillNumber, CourtNumber])
useEffect(() => {
BillNumber && CourtNumber ? hearingBill() : null
}, [BillNumber, hearingBill, CourtNumber])
- let committeeActions = []
-
- committeeRecommendations
- ? (committeeActions = committeeRecommendations.filter(
- (action: any) => action.Committee.CommitteeCode === committeeCode
- ))
- : null
-
return (
<>
@@ -401,7 +422,7 @@ function AgendaBill({
{BillNumber}
{element.Title}
- {committeeActions[0]?.Votes[0]?.Question ? (
+ {committeeActions.length > 0 ? (
- setSettingsModal(null)}
- show={settingsModal === "show"}
- />
+ {committeeActions.length > 0 ? (
+ setSettingsModal(null)}
+ show={settingsModal === "show"}
+ />
+ ) : null}
>
)
}
type Props = Pick & {
BillNumber: string
- committeeActions: any
+ committeeActions: CommitteeRecommendation[]
CourtNumber: number
generalCourtNumber: string | null
onSettingsModalClose: () => void
@@ -446,6 +469,25 @@ function VotesModal({
show
}: Props) {
const { t } = useTranslation(["common", "editProfile", "hearing"])
+ const votes: CommitteeVote[] = []
+ const [selectedVote, setSelectedVote] = useState(0)
+ for (const action of committeeActions) {
+ for (const vote of action.Votes!) {
+ // Merge different vote arrays into [0]
+ const record = vote.Vote!.reduce((acc, vote) => {
+ for (const [key, values] of Object.entries(vote) as [
+ keyof CommitteeVoteRecord,
+ LegislativeMemberSummary[]
+ ][]) {
+ acc[key] ??= []
+ acc[key]!.push(...values)
+ }
+ return acc
+ }, {})
+ vote.Vote = [record]
+ votes.push(vote)
+ }
+ }
return (
@@ -463,16 +505,40 @@ function VotesModal({
/>
-
- {committeeActions[0]?.Votes[0]?.Question}
+
+ {votes.length > 1 ? (
+
+
+ {votes[selectedVote].Question}
+
+
+
+ {votes.map((vote, n) => (
+ setSelectedVote(n)}
+ >
+ {vote.Question}
+
+ ))}
+
+
+ ) : (
+ votes[selectedVote].Question
+ )}
{t("yes", { ns: "hearing" })} (
- {committeeActions[0]?.Votes[0]?.Vote[0]?.Favorable.length})
+ {votes[selectedVote]?.Vote![0].Favorable?.length ?? 0})
{generalCourtNumber &&
- committeeActions[0]?.Votes[0]?.Vote[0]?.Favorable.map(
+ (votes[selectedVote]?.Vote![0].Favorable ?? []).map(
(element: any, index: number) => (
{t("no", { ns: "hearing" })} (
- {committeeActions[0]?.Votes[0]?.Vote[0]?.Adverse.length})
+ {votes[selectedVote].Vote![0].Adverse?.length ?? 0})
{generalCourtNumber &&
- committeeActions[0]?.Votes[0]?.Vote[0]?.Adverse.map(
+ (votes[selectedVote]?.Vote![0].Adverse ?? []).map(
(element: any, index: number) => (
{t("no_vote", { ns: "hearing" })} (
- {committeeActions[0]?.Votes[0]?.Vote[0]?.NoVoteRecorded.length})
+ {votes[selectedVote]?.Vote![0].NoVoteRecorded?.length ?? 0})
{generalCourtNumber &&
- committeeActions[0]?.Votes[0]?.Vote[0]?.NoVoteRecorded.map(
+ (votes[selectedVote]?.Vote![0].NoVoteRecorded ?? []).map(
(element: any, index: number) => (