From ea73d17a6a2db4b7f41ab787446622633b35cae2 Mon Sep 17 00:00:00 2001 From: janicelichtman Date: Tue, 7 Jul 2026 15:07:45 -0400 Subject: [PATCH 1/7] Added campaign finance scraper --- .../LegislatorProfilePage.tsx | 9 +- .../LegislatorProfile/LegislatorTabs.tsx | 7 +- .../TabComponents/FinanceTab.tsx | 187 +++++- components/db/membersFinance.ts | 73 +++ functions/src/index.ts | 1 + functions/src/ocpf/scrapeOcpfFinance.ts | 565 ++++++++++++++++++ functions/src/ocpf/types.ts | 38 ++ package.json | 2 + public/locales/en/legislators.json | 19 + yarn.lock | 249 ++++++++ 10 files changed, 1144 insertions(+), 6 deletions(-) create mode 100644 components/db/membersFinance.ts create mode 100644 functions/src/ocpf/scrapeOcpfFinance.ts diff --git a/components/LegislatorProfile/LegislatorProfilePage.tsx b/components/LegislatorProfile/LegislatorProfilePage.tsx index 2f8d127e2..5b6405b2d 100644 --- a/components/LegislatorProfile/LegislatorProfilePage.tsx +++ b/components/LegislatorProfile/LegislatorProfilePage.tsx @@ -20,6 +20,7 @@ import { LegislatorTabs } from "./LegislatorTabs" import { useAuth } from "components/auth" import { Col, Container, Row, Spinner } from "components/bootstrap" import { useDistrict, useMember } from "components/db" +import { useMembersFinance } from "components/db/membersFinance" import { Internal } from "components/links" import { FollowUserButton } from "components/shared/FollowButton" import { CircleImage } from "components/shared/LabeledIcon" @@ -130,6 +131,7 @@ export function LegislatorProfilePage({ member?.Branch, member?.District ) + const { finance } = useMembersFinance(court, memberCode) const { t } = useTranslation("legislators") // const { user } = useAuth() **uncomment when Following Button in enabled** @@ -343,7 +345,11 @@ export function LegislatorProfilePage({ - ? + + {finance?.totalRaised != null + ? "$" + Math.round(finance.totalRaised).toLocaleString() + : "?"} + {t("fundsRaised")} @@ -354,6 +360,7 @@ export function LegislatorProfilePage({ diff --git a/components/LegislatorProfile/LegislatorTabs.tsx b/components/LegislatorProfile/LegislatorTabs.tsx index 40c264453..d6ba55d2b 100644 --- a/components/LegislatorProfile/LegislatorTabs.tsx +++ b/components/LegislatorProfile/LegislatorTabs.tsx @@ -19,6 +19,7 @@ import { TabNavWrapper, TabType } from "components/EditProfilePage/StyledEditProfileComponents" +import { MembersFinance } from "components/db/membersFinance" const tabCategory = [ "priorities", @@ -66,11 +67,13 @@ const TabNavItem = ({ export function LegislatorTabs({ district, districtLoading, - tabCategory + tabCategory, + finance }: { district?: District | undefined districtLoading?: boolean tabCategory?: TabCategories + finance?: MembersFinance }) { const { t } = useTranslation("legislators") @@ -93,7 +96,7 @@ export function LegislatorTabs({ { title: t("tabs.finance"), eventKey: "finance", - content: + content: }, { title: t("tabs.district"), diff --git a/components/LegislatorProfile/TabComponents/FinanceTab.tsx b/components/LegislatorProfile/TabComponents/FinanceTab.tsx index 4c263d26a..cc0926f7d 100644 --- a/components/LegislatorProfile/TabComponents/FinanceTab.tsx +++ b/components/LegislatorProfile/TabComponents/FinanceTab.tsx @@ -1,5 +1,186 @@ -import { TabBlock } from "../LegislatorComponents" +import dynamic from "next/dynamic" +import { useTranslation } from "next-i18next" +import { MembersFinance } from "components/db/membersFinance" -export function FinanceTab() { - return - Finance +const SLICE_COLORS = [ + "#1a3185", + "#4e6fcf", + "#7b9dd9", + "#a8c0e8", + "#2e7d32", + "#81c784" +] + +function formatCurrency(n?: number | null): string { + if (n == null || isNaN(n)) return "$0" + return "$" + Math.round(n).toLocaleString() +} + +function formatPct(value: number, total: number): string { + if (total === 0) return "0%" + return Math.round((value / total) * 100) + "%" +} + +interface ChartSlice { + name: string + value: number +} + +interface FinancePieChartProps { + slices: ChartSlice[] + total: number +} + +const FinancePieChart = dynamic( + async () => { + const { PieChart, Pie, Cell, Tooltip } = await import("recharts") + + return function FinancePieChartInner({ + slices, + total + }: FinancePieChartProps) { + return ( +
+ + + {slices.map((_, i) => ( + + ))} + + + typeof value === "number" + ? formatCurrency(value) + : String(value) + } + /> + + +
+ {slices.map((s, i) => ( +
+
+ {s.name} + + {formatPct(s.value, total)} + + + {formatCurrency(s.value)} + +
+ ))} +
+
+ ) + } + }, + { ssr: false } +) + +export function FinanceTab({ finance }: { finance?: MembersFinance }) { + const { t } = useTranslation("legislators") + + if (!finance) { + return

{t("finance.noData")}

+ } + + const inKindTotal = + (finance.inKind?.individual?.amount ?? 0) + + (finance.inKind?.committee?.amount ?? 0) + + (finance.inKind?.union?.amount ?? 0) + + (finance.inKind?.unitemized?.amount ?? 0) + + const candidateFundsTotal = + (finance.candidateFunds?.loans?.amount ?? 0) + + (finance.candidateFunds?.contributions?.amount ?? 0) + + const slices: ChartSlice[] = [ + { + name: t("finance.breakdown.individual"), + value: finance.breakdown?.individual?.amount ?? 0 + }, + { + name: t("finance.breakdown.committee"), + value: finance.breakdown?.committee?.amount ?? 0 + }, + { + name: t("finance.breakdown.union"), + value: finance.breakdown?.union?.amount ?? 0 + }, + { + name: t("finance.breakdown.unitemized"), + value: finance.breakdown?.unitemized?.amount ?? 0 + }, + { name: t("finance.breakdown.candidateFunds"), value: candidateFundsTotal }, + { name: t("finance.breakdown.inKind"), value: inKindTotal } + ].filter(s => s.value > 0) + + const total = slices.reduce((sum, s) => sum + s.value, 0) + const nonContribution = finance.otherReceipts?.nonContribution?.amount ?? 0 + + return ( +
+
+ {[ + { + label: t("finance.stats.totalRaised"), + value: finance.totalRaised + }, + { label: t("finance.stats.totalSpent"), value: finance.totalSpent }, + { label: t("finance.stats.cashOnHand"), value: finance.cashOnHand } + ].map(({ label, value }) => ( +
+
+ {formatCurrency(value)} +
+
{label}
+
+ ))} +
+ +
{t("finance.influenceHeading")}
+ + {slices.length === 0 ? ( +

{t("finance.noContributions")}

+ ) : ( + + )} + + {nonContribution > 0 && ( +

+ {t("finance.otherReceipts", { + amount: formatCurrency(nonContribution) + })} +

+ )} +
+ ) } diff --git a/components/db/membersFinance.ts b/components/db/membersFinance.ts new file mode 100644 index 000000000..e5c4fa0bf --- /dev/null +++ b/components/db/membersFinance.ts @@ -0,0 +1,73 @@ +import { Timestamp } from "firebase/firestore" +import { useMemo } from "react" +import { useAsync } from "react-async-hook" +import { loadDoc } from "./common" + +// Mirror of functions/src/ocpf/types.ts MembersFinance, using client-side Timestamp +export interface FinanceBreakdownEntry { + count: number + amount: number +} + +export interface MembersFinanceBreakdown { + individual: FinanceBreakdownEntry + committee: FinanceBreakdownEntry + union: FinanceBreakdownEntry + unitemized: { amount: number } +} + +export interface MembersFinanceCandidateFunds { + loans: FinanceBreakdownEntry + contributions: FinanceBreakdownEntry +} + +export interface MembersFinanceInKind { + individual: FinanceBreakdownEntry + committee: FinanceBreakdownEntry + union: FinanceBreakdownEntry + unitemized: { amount: number } +} + +export interface MembersFinanceOtherReceipts { + nonContribution: FinanceBreakdownEntry +} + +export interface MembersFinanceYearData { + totalRaised: number + totalSpent: number + breakdown: MembersFinanceBreakdown + finalized: boolean +} + +export interface MembersFinance { + ocpfCpfId: number + totalRaised: number + totalSpent: number + cashOnHand: number + contributorCount: number + lastUpdated: Timestamp + breakdown: MembersFinanceBreakdown + candidateFunds: MembersFinanceCandidateFunds + inKind: MembersFinanceInKind + otherReceipts: MembersFinanceOtherReceipts + years: Record +} + +export function useMembersFinance(court: number, memberId?: string) { + const { loading, result, error } = useAsync(getFinance, [court, memberId]) + return useMemo( + () => ({ finance: result, loading, error }), + [loading, result, error] + ) +} + +async function getFinance( + court: number, + memberId?: string +): Promise { + if (!memberId) return undefined + const data = await loadDoc( + `/generalCourts/${court}/membersFinance/${memberId}` + ) + return data as MembersFinance | undefined +} diff --git a/functions/src/index.ts b/functions/src/index.ts index 796a30871..771b60c88 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -61,6 +61,7 @@ export { export { transcription } from "./webhooks" export { matchOcpfMembers } from "./ocpf/matchOcpfMembers" +export { scrapeOcpfFinance } from "./ocpf/scrapeOcpfFinance" export * from "./triggerPubsubFunction" diff --git a/functions/src/ocpf/scrapeOcpfFinance.ts b/functions/src/ocpf/scrapeOcpfFinance.ts new file mode 100644 index 000000000..478b40339 --- /dev/null +++ b/functions/src/ocpf/scrapeOcpfFinance.ts @@ -0,0 +1,565 @@ +// TODO: After validating output against the OCPF website, flip to: +// export const scrapeOcpfFinance = functions.pubsub.schedule("every 24 hours").onRun(...) +import * as functions from "firebase-functions" +import { getAuth } from "firebase-admin/auth" +import axios from "axios" +import unzipper from "unzipper" +import * as readline from "readline" +import { db, Timestamp } from "../firebase" +import { currentGeneralCourt } from "../shared" +import { + OcpfMemberMapping, + MembersFinance, + MembersFinanceBreakdown, + MembersFinanceCandidateFunds, + MembersFinanceInKind, + MembersFinanceOtherReceipts, + MembersFinanceYearData +} from "./types" + +// Set to null to run all members. Validate single-member output first. +const TEST_CPF_ID: number | null = 16883 // Rebecca L. Rausch, RLR0 + +const OCPF_BASE_URL = "https://ocpf2.blob.core.windows.net/downloads/data2" + +const YEARS = ["2025", "2026"] + +// Annual rollup report types to be excluded. +// Including these would double-count both receipts and expeditures. +// Note: 32, 36, 45, and 52 not relevant to individual legislators, but not harmful to exclude +const YEAR_END_REPORT_TYPE_IDS = new Set([ + 11, // Year-End Report (Depository) + 24, // Year-End Report (Non-Depository) + 32, // Year-End Report (PAC) + 36, // IEPAC Year-End Report + 45, // Year-End Report (Ballot Question Committee) + 52, // Year-End Report (Local Party Committee) + 113 // Year-End Report (Municipal) +]) + +// ── Accumulator types ───────────────────────────────────────────────────────── + +interface MutableBreakdownEntry { + count: number + amount: number +} + +interface MemberAccumulator { + cpfId: number + totalRaised: number + totalSpent: number + cashOnHand: number + cashOnHandEndDateMs: number // End_Date (as ms) of the most recent bank report seen + contributorCount: number + breakdown: { + individual: MutableBreakdownEntry + committee: MutableBreakdownEntry + union: MutableBreakdownEntry + unitemized: { amount: number } + } + candidateFunds: { + loans: MutableBreakdownEntry + contributions: MutableBreakdownEntry + } + inKind: { + individual: MutableBreakdownEntry + committee: MutableBreakdownEntry + union: MutableBreakdownEntry + unitemized: { amount: number } + } + otherReceipts: { + nonContribution: MutableBreakdownEntry + } + years: Record< + string, + { + totalRaised: number + totalSpent: number + breakdown: { + individual: MutableBreakdownEntry + committee: MutableBreakdownEntry + union: MutableBreakdownEntry + unitemized: { amount: number } + } + } + > + yearEndCheck: Record< + string, + { receiptsTotal: number; expendituresTotal: number } | null + > +} + +function emptyEntry(): MutableBreakdownEntry { + return { count: 0, amount: 0 } +} + +function newAccumulator(cpfId: number): MemberAccumulator { + const yearInit = () => ({ + totalRaised: 0, + totalSpent: 0, + breakdown: { + individual: emptyEntry(), + committee: emptyEntry(), + union: emptyEntry(), + unitemized: { amount: 0 } + } + }) + return { + cpfId, + totalRaised: 0, + totalSpent: 0, + cashOnHand: 0, + cashOnHandEndDateMs: 0, + contributorCount: 0, + breakdown: { + individual: emptyEntry(), + committee: emptyEntry(), + union: emptyEntry(), + unitemized: { amount: 0 } + }, + candidateFunds: { loans: emptyEntry(), contributions: emptyEntry() }, + inKind: { + individual: emptyEntry(), + committee: emptyEntry(), + union: emptyEntry(), + unitemized: { amount: 0 } + }, + otherReceipts: { nonContribution: emptyEntry() }, + years: Object.fromEntries(YEARS.map(y => [y, yearInit()])), + yearEndCheck: Object.fromEntries(YEARS.map(y => [y, null])) + } +} + +// ── Cloud Function ──────────────────────────────────────────────────────────── + +export const scrapeOcpfFinance = functions + .runWith({ timeoutSeconds: 540, memory: "512MB" }) + .https.onRequest(async (req, res) => { + if (req.method !== "POST") { + res.status(405).send("Method Not Allowed. Use POST.") + return + } + if (process.env.FUNCTIONS_EMULATOR !== "true") { + const authHeader = req.headers.authorization + if (!authHeader?.startsWith("Bearer ")) { + res.status(401).send("Unauthorized") + return + } + try { + const decoded = await getAuth().verifyIdToken(authHeader.slice(7)) + if (decoded["role"] !== "admin") { + res.status(403).send("Forbidden") + return + } + } catch { + res.status(401).send("Unauthorized") + return + } + } + + // ── A. Load member mapping ───────────────────────────────────────────── + const mappingDoc = await db.doc("/config/ocpfMemberMapping").get() + const mapping = (mappingDoc.data() ?? {}) as OcpfMemberMapping + + // Build cpfId → memberCode reverse map + let cpfIdToMemberCode = new Map( + Object.entries(mapping).map(([memberCode, entry]) => [ + entry.cpfId, + memberCode + ]) + ) + + if (TEST_CPF_ID !== null) { + cpfIdToMemberCode = new Map( + [...cpfIdToMemberCode.entries()].filter( + ([cpfId]) => cpfId === TEST_CPF_ID + ) + ) + functions.logger.info("TEST MODE: filtering to single member", { + cpfId: TEST_CPF_ID + }) + } + + functions.logger.info("Loaded member mapping", { + totalMembers: Object.keys(mapping).length, + activeInRun: cpfIdToMemberCode.size + }) + + // ── B. Download each year's ZIP; parse reports.txt then report-items.txt ── + const accumulators = new Map() + + // reportId → memberCode, for joining with report-items + const reportIdToMemberCode = new Map() + + for (const year of YEARS) { + const url = `${OCPF_BASE_URL}/ocpf-${year}-reports.zip` + functions.logger.info(`Downloading ${url}`) + const buf = await downloadBuffer(url) + await parseReports( + buf, + year, + cpfIdToMemberCode, + accumulators, + reportIdToMemberCode + ) + + functions.logger.info(`Streaming report-items for ${year}`) + await streamReportItems(buf, reportIdToMemberCode, accumulators, year) + } + + // ── Reconciliation: year-end report vs. summed periodic totals ──────── + // Year-end reports (type 11, etc.) are excluded from accumulation because + // their Receipts_Total/Expenditures_Total are annual rollups that duplicate + // the periodic (Bank Report) totals. + // This check runs only once a year-end report exists (i.e. + // after the calendar year closes) and compares it against what we've summed. + // + // A mismatch most likely means some other report type is being double-counted + // in the periodic accumulation. Deposit Reports (type 60) are a prime suspect: + // they itemize the same bank deposits the Bank Reports already capture and have + // not been verified yet. Do not ignore a mismatch — investigate the source + // before trusting the displayed totals. + for (const [memberCode, acc] of accumulators) { + for (const year of YEARS) { + const check = acc.yearEndCheck[year] + if (!check) continue + const summedRaised = acc.years[year]?.totalRaised ?? 0 + const summedSpent = acc.years[year]?.totalSpent ?? 0 + const raisedDiff = Math.abs(check.receiptsTotal - summedRaised) + const spentDiff = Math.abs(check.expendituresTotal - summedSpent) + if (raisedDiff > 0.02 || spentDiff > 0.02) { + functions.logger.warn( + "Year-end totals mismatch — investigate for double-counting", + { + memberCode, + year, + yearEnd: { + receiptsTotal: check.receiptsTotal, + expendituresTotal: check.expendituresTotal + }, + summed: { + receiptsTotal: summedRaised, + expendituresTotal: summedSpent + }, + diff: { receipts: raisedDiff, expenditures: spentDiff } + } + ) + } + } + } + + // ── C. Write Firestore docs ─────────────────────────────────────────── + const now = Timestamp.now() + // Firestore batches are limited to 500 operations. MA general courts have ~200 members + // so this is fine, but if we ever exceed 500 members this will need to be chunked. + const batch = db.batch() + + for (const [memberCode, acc] of accumulators) { + const doc = db.doc( + `/generalCourts/${currentGeneralCourt}/membersFinance/${memberCode}` + ) + const data: MembersFinance = { + ocpfCpfId: acc.cpfId, + totalRaised: acc.totalRaised, + totalSpent: acc.totalSpent, + cashOnHand: acc.cashOnHand, + contributorCount: acc.contributorCount, + lastUpdated: now, + breakdown: acc.breakdown as MembersFinanceBreakdown, + candidateFunds: acc.candidateFunds as MembersFinanceCandidateFunds, + inKind: acc.inKind as MembersFinanceInKind, + otherReceipts: acc.otherReceipts as MembersFinanceOtherReceipts, + years: Object.fromEntries( + Object.entries(acc.years).map(([y, yd]) => [ + y, + { + totalRaised: yd.totalRaised, + totalSpent: yd.totalSpent, + breakdown: yd.breakdown as MembersFinanceBreakdown, + finalized: acc.yearEndCheck[y] !== null + } as MembersFinanceYearData + ]) + ) + } + batch.set(doc, data) + } + + await batch.commit() + + functions.logger.info("scrapeOcpfFinance complete", { + processed: accumulators.size, + years: YEARS + }) + + res.status(200).json({ + results: { processed: accumulators.size, years: YEARS } + }) + }) + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +async function downloadBuffer(url: string): Promise { + const response = await axios.get(url, { responseType: "arraybuffer" }) + return Buffer.from(response.data as ArrayBuffer) +} + +const REPORT_COLUMN_ALIASES: Record = { + cpfId: ["cpf_id"], + reportId: ["report_id"], + reportTypeId: ["report_type_id"], + receiptsTotal: ["receipts_total"], + receiptsUnitemizedTotal: ["receipts_unitemized_total"], + expendituresTotal: ["expenditures_total"], + endBalance: ["end_balance"], + endDate: ["end_date"] +} + +const ITEM_COLUMN_ALIASES: Record = { + reportId: ["report_id"], + recordTypeId: ["record_type_id"], + amount: ["amount"] +} + +function buildIndex( + headers: string[], + aliases: Record +): Record { + const normalized = headers.map(h => h.toLowerCase().replace(/\s+/g, "_")) + const index: Record = {} + for (const [field, aliasList] of Object.entries(aliases)) { + for (const alias of aliasList) { + const i = normalized.indexOf(alias) + if (i !== -1) { + index[field] = i + break + } + } + if (!(field in index)) { + throw new Error( + `Required column '${field}' not found. Headers: ${headers.join(", ")}` + ) + } + } + return index +} + +function col(cols: string[], idx: number): string { + return (cols[idx] ?? "").trim().replace(/^"|"$/g, "") +} + +async function parseReports( + buf: Buffer, + year: string, + cpfIdToMemberCode: Map, + accumulators: Map, + reportIdToMemberCode: Map +): Promise { + const directory = await unzipper.Open.buffer(buf) + const entry = directory.files.find( + f => f.type === "File" && f.path.toLowerCase() === "reports.txt" + ) + if (!entry) throw new Error(`reports.txt not found in zip`) + + const text = (await entry.buffer()).toString("utf8") + const lines = text.split(/\r?\n/) + const rawHeaders = lines[0].split("\t").map(h => h.trim()) + const idx = buildIndex(rawHeaders, REPORT_COLUMN_ALIASES) + + let matched = 0 + for (let i = 1; i < lines.length; i++) { + const line = lines[i] + if (!line.trim()) continue + + const cols = line.split("\t") + const cpfId = parseInt(col(cols, idx.cpfId), 10) + const memberCode = cpfIdToMemberCode.get(cpfId) + if (!memberCode) continue + + const reportId = parseInt(col(cols, idx.reportId), 10) + if (isNaN(reportId)) continue + const reportTypeId = parseInt(col(cols, idx.reportTypeId), 10) + const receiptsTotal = parseFloat(col(cols, idx.receiptsTotal)) || 0 + const receiptsUnitemized = + parseFloat(col(cols, idx.receiptsUnitemizedTotal)) || 0 + const expendituresTotal = parseFloat(col(cols, idx.expendituresTotal)) || 0 + const endBalance = parseFloat(col(cols, idx.endBalance)) || 0 + + let acc = accumulators.get(memberCode) + if (!acc) { + acc = newAccumulator(cpfId) + accumulators.set(memberCode, acc) + } + + if (YEAR_END_REPORT_TYPE_IDS.has(reportTypeId)) { + // Annual rollup — skip accumulation to avoid double-counting the periodic + // totals already summed above. Store for the reconciliation check below. + if (acc.yearEndCheck[year] === null) { + acc.yearEndCheck[year] = { receiptsTotal, expendituresTotal } + } + matched++ + continue + } + + // Credit Card (type 80) and Reimbursement (type 90) reports are supplemental: + // they itemize spending that the depository bank already captured as bank + // transactions in the monthly Bank Report's Expenditures_Total. Verified + // empirically — for two members the sum of all Bank Reports matched the + // Year-End Report exactly without including type 80/90 amounts, confirming + // their Expenditures_Total is already counted. Their items (354 Credit Card + // Sub-Items, 351 Reimbursement Sub-Items) are not used by accumulateItem(), + // so there is no reason to register their report IDs. + if (reportTypeId === 80 || reportTypeId === 90) { + matched++ + continue + } + + reportIdToMemberCode.set(reportId, memberCode) + + // Deposit Reports (type 60) carry gross contribution amounts (before fees). + // The same money appears net-of-fees in Bank Report (type 70) Receipts_Total, + // so adding both would double-count. Skip the totals for type 60 but keep + // its report_id registered (for 201/202/203/204 item-level breakdown) and + // its unitemized amount (Bank Reports have blank for that field). + if (reportTypeId !== 60) { + acc.totalRaised += receiptsTotal + acc.totalSpent += expendituresTotal + } + acc.breakdown.unitemized.amount += receiptsUnitemized + + if (acc.years[year]) { + if (reportTypeId !== 60) { + acc.years[year].totalRaised += receiptsTotal + acc.years[year].totalSpent += expendituresTotal + } + acc.years[year].breakdown.unitemized.amount += receiptsUnitemized + } + + // Report_Type_ID 70 = Bank Report — use End_Balance from the report with the latest End_Date + const endDate = col(cols, idx.endDate) + const endDateMs = endDate ? new Date(endDate).getTime() : 0 + if (reportTypeId === 70 && endDateMs > acc.cashOnHandEndDateMs) { + acc.cashOnHand = endBalance + acc.cashOnHandEndDateMs = endDateMs + } + + matched++ + } + + functions.logger.info(`Parsed reports.txt for ${year}`, { matched }) +} + +async function streamReportItems( + buf: Buffer, + reportIdToMemberCode: Map, + accumulators: Map, + year: string +): Promise { + const directory = await unzipper.Open.buffer(buf) + const entry = directory.files.find( + f => f.type === "File" && f.path.toLowerCase() === "report-items.txt" + ) + if (!entry) throw new Error(`report-items.txt not found in zip`) + + const stream = entry.stream() + const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }) + + let isFirst = true + let idx: Record = {} + let processed = 0 + let skipped = 0 + + for await (const line of rl) { + if (!line.trim()) continue + + if (isFirst) { + const rawHeaders = line.split("\t").map(h => h.trim()) + idx = buildIndex(rawHeaders, ITEM_COLUMN_ALIASES) + isFirst = false + continue + } + + const cols = line.split("\t") + const reportId = parseInt(col(cols, idx.reportId), 10) + const memberCode = reportIdToMemberCode.get(reportId) + if (!memberCode) { + skipped++ + continue + } + + const acc = accumulators.get(memberCode) + if (!acc) { + skipped++ + continue + } + + const recordTypeId = parseInt(col(cols, idx.recordTypeId), 10) + const amount = parseFloat(col(cols, idx.amount)) || 0 + + accumulateItem(acc, recordTypeId, amount, year) + processed++ + } + + functions.logger.info(`Streamed report-items.txt for ${year}`, { + processed, + skipped + }) +} + +function accumulateItem( + acc: MemberAccumulator, + recordTypeId: number, + amount: number, + year: string +): void { + const addTo = ( + entry: MutableBreakdownEntry, + alsoYearBreakdown?: MutableBreakdownEntry + ) => { + entry.count++ + entry.amount += amount + if (alsoYearBreakdown) { + alsoYearBreakdown.count++ + alsoYearBreakdown.amount += amount + } + } + + const yb = acc.years[year]?.breakdown + + switch (recordTypeId) { + case 201: // Individual Contribution + addTo(acc.breakdown.individual, yb?.individual) + acc.contributorCount++ + break + case 202: // Committee Contribution + addTo(acc.breakdown.committee, yb?.committee) + break + case 203: // Union/Association Contribution + addTo(acc.breakdown.union, yb?.union) + break + case 204: // Non-contribution receipt + addTo(acc.otherReceipts.nonContribution) + break + case 206: // Candidate Loan + case 331: // Out-of-pocket expense (as loan) + addTo(acc.candidateFunds.loans) + break + case 332: // Out-of-pocket expense (as contribution) + addTo(acc.candidateFunds.contributions) + break + case 401: // Individual In-kind + addTo(acc.inKind.individual) + break + case 402: // Committee In-kind + addTo(acc.inKind.committee) + break + case 403: // Union In-kind + addTo(acc.inKind.union) + break + case 420: // Aggregated un-itemized in-kind + acc.inKind.unitemized.amount += amount + break + // 205 (Bank Interest) and 220 (Aggregated un-itemized) come from reports.txt, not items + default: + break + } +} diff --git a/functions/src/ocpf/types.ts b/functions/src/ocpf/types.ts index ae61e2ce9..46165495d 100644 --- a/functions/src/ocpf/types.ts +++ b/functions/src/ocpf/types.ts @@ -39,3 +39,41 @@ export interface MembersFinanceBreakdown { union: FinanceBreakdownEntry unitemized: { amount: number } } + +export interface MembersFinanceCandidateFunds { + loans: FinanceBreakdownEntry // types 206 + 331 + contributions: FinanceBreakdownEntry // type 332 +} + +export interface MembersFinanceInKind { + individual: FinanceBreakdownEntry // type 401 + committee: FinanceBreakdownEntry // type 402 + union: FinanceBreakdownEntry // type 403 + unitemized: { amount: number } // type 420 +} + +export interface MembersFinanceOtherReceipts { + nonContribution: FinanceBreakdownEntry // type 204 +} + +export interface MembersFinanceYearData { + totalRaised: number + totalSpent: number + breakdown: MembersFinanceBreakdown + finalized: boolean +} + +// Firestore: /generalCourts/{court}/membersFinance/{memberCode} +export interface MembersFinance { + ocpfCpfId: number + totalRaised: number + totalSpent: number + cashOnHand: number + contributorCount: number // count of type-201 rows (row = one itemized contribution) + lastUpdated: FirebaseFirestore.Timestamp + breakdown: MembersFinanceBreakdown + candidateFunds: MembersFinanceCandidateFunds + inKind: MembersFinanceInKind + otherReceipts: MembersFinanceOtherReceipts + years: Record +} diff --git a/package.json b/package.json index 9a0329989..906a9f70e 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "@react-aria/ssr": "^3.2.0", "@react-aria/utils": "^3.13.1", "@reduxjs/toolkit": "^1.8.3", + "@swc/core-darwin-arm64": "^1.15.43", "@testing-library/dom": "^9.3.3", "autolinker": "^3.16.0", "awesome-debounce-promise": "^2.1.0", @@ -131,6 +132,7 @@ "react-query": "^3.39.3", "react-redux": "^8.0.2", "react-select": "^5.2.2", + "recharts": "^3.9.0", "redux-mock-store": "^1.5.4", "redux-thunk": "^3.1.0", "runtypes": "6.6.0", diff --git a/public/locales/en/legislators.json b/public/locales/en/legislators.json index 886f6a1ba..93c9bd742 100644 --- a/public/locales/en/legislators.json +++ b/public/locales/en/legislators.json @@ -4,6 +4,25 @@ "contact": "Contact", "cosponsored": "Cosponsored", "districtDetails": "District details are not available yet.", + "finance": { + "breakdown": { + "candidateFunds": "Candidate's own funds", + "committee": "Committees / PACs", + "inKind": "In-kind contributions", + "individual": "Individual donors", + "union": "Unions & associations", + "unitemized": "Small donors (unitemized)" + }, + "influenceHeading": "Where the Influence Comes From — 2025–2026 Election Cycle", + "noContributions": "No itemized contributions recorded yet.", + "noData": "Finance data not yet available for this legislator.", + "otherReceipts": "Other non-contribution receipts: {{amount}} (refunds and miscellaneous receipts — not included above)", + "stats": { + "cashOnHand": "Cash on Hand", + "totalRaised": "Total Raised", + "totalSpent": "Total Spent" + } + }, "fundsRaised": "Funds Raised", "home": "Home", "legislators": "Legislators", diff --git a/yarn.lock b/yarn.lock index 2d0c0348b..36661765d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3406,6 +3406,18 @@ redux-thunk "^2.4.2" reselect "^4.1.8" +"@reduxjs/toolkit@^1.9.0 || 2.x.x": + version "2.12.0" + resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-2.12.0.tgz#e62787503a38561e04bb8f39e29ca8db689590f9" + integrity sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw== + dependencies: + "@standard-schema/spec" "^1.0.0" + "@standard-schema/utils" "^0.3.0" + immer "^11.0.0" + redux "^5.0.1" + redux-thunk "^3.1.0" + reselect "^5.1.0" + "@remix-run/router@1.13.1": version "1.13.1" resolved "https://registry.npmjs.org/@remix-run/router/-/router-1.13.1.tgz" @@ -3484,6 +3496,16 @@ dependencies: "@sinonjs/commons" "^3.0.0" +"@standard-schema/spec@^1.0.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" + integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== + +"@standard-schema/utils@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b" + integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g== + "@storybook/addon-actions@7.6.4", "@storybook/addon-actions@^7.6.4": version "7.6.4" resolved "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-7.6.4.tgz" @@ -4311,6 +4333,11 @@ resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.100.tgz#f582c5bbc9c49506f728fc1d14dff33c2cc226d5" integrity sha512-XVWFsKe6ei+SsDbwmsuRkYck1SXRpO60Hioa4hoLwR8fxbA9eVp6enZtMxzVVMBi8ej5seZ4HZQeAWepbukiBw== +"@swc/core-darwin-arm64@^1.15.43": + version "1.15.43" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.43.tgz#386294f8427dde2df1a70dd0a5826d67af70e996" + integrity sha512-v1aVuvXdo/BHxJzco9V2xpHrvwWmhfS8t6gziY5wJxd+Z2h8AeJRnAwPD8itCDaGXVBwJ/CaKfxEzTkG0Va0OA== + "@swc/core-darwin-x64@1.15.21": version "1.15.21" resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.21.tgz#05ff28c00a7045d9760c847e19604fff02b6e3ea" @@ -4632,6 +4659,57 @@ dependencies: "@types/node" "*" +"@types/d3-array@^3.0.3": + version "3.2.2" + resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.2.2.tgz#e02151464d02d4a1b44646d0fcdb93faf88fde8c" + integrity sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw== + +"@types/d3-color@*": + version "3.1.3" + resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.3.tgz#368c961a18de721da8200e80bf3943fb53136af2" + integrity sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A== + +"@types/d3-ease@^3.0.0": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-ease/-/d3-ease-3.0.2.tgz#e28db1bfbfa617076f7770dd1d9a48eaa3b6c51b" + integrity sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA== + +"@types/d3-interpolate@^3.0.1": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c" + integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA== + dependencies: + "@types/d3-color" "*" + +"@types/d3-path@*": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.1.1.tgz#f632b380c3aca1dba8e34aa049bcd6a4af23df8a" + integrity sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg== + +"@types/d3-scale@^4.0.2": + version "4.0.9" + resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.9.tgz#57a2f707242e6fe1de81ad7bfcccaaf606179afb" + integrity sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw== + dependencies: + "@types/d3-time" "*" + +"@types/d3-shape@^3.1.0": + version "3.1.8" + resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.8.tgz#d1516cc508753be06852cd06758e3bb54a22b0e3" + integrity sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w== + dependencies: + "@types/d3-path" "*" + +"@types/d3-time@*", "@types/d3-time@^3.0.0": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.4.tgz#8472feecd639691450dd8000eb33edd444e1323f" + integrity sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g== + +"@types/d3-timer@^3.0.0": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.2.tgz#70bbda77dc23aa727413e22e214afa3f0e852f70" + integrity sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw== + "@types/debounce-promise@^3.1.1": version "3.1.9" resolved "https://registry.npmjs.org/@types/debounce-promise/-/debounce-promise-3.1.9.tgz" @@ -5125,6 +5203,11 @@ resolved "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz" integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA== +"@types/use-sync-external-store@^0.0.6": + version "0.0.6" + resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz#60be8d21baab8c305132eb9cb912ed497852aadc" + integrity sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg== + "@types/uuid@^9.0.1": version "9.0.7" resolved "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.7.tgz" @@ -7578,6 +7661,77 @@ csv-parse@^5.0.4: resolved "https://registry.npmjs.org/csv-parse/-/csv-parse-5.5.3.tgz" integrity sha512-v0KW6C0qlZzoGjk6u5tLmVfyZxNgPGXZsWTXshpAgKVGmGXzaVWGdlCFxNx5iuzcXT/oJN1HHM9DZKwtAtYa+A== +"d3-array@2 - 3", "d3-array@2.10.0 - 3", d3-array@^3.1.6: + version "3.2.4" + resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.4.tgz#15fec33b237f97ac5d7c986dc77da273a8ed0bb5" + integrity sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg== + dependencies: + internmap "1 - 2" + +"d3-color@1 - 3": + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2" + integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA== + +d3-ease@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4" + integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w== + +"d3-format@1 - 3": + version "3.1.2" + resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.2.tgz#01fdb46b58beb1f55b10b42ad70b6e344d5eb2ae" + integrity sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg== + +"d3-interpolate@1.2.0 - 3", d3-interpolate@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d" + integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g== + dependencies: + d3-color "1 - 3" + +d3-path@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.1.0.tgz#22df939032fb5a71ae8b1800d61ddb7851c42526" + integrity sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ== + +d3-scale@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396" + integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ== + dependencies: + d3-array "2.10.0 - 3" + d3-format "1 - 3" + d3-interpolate "1.2.0 - 3" + d3-time "2.1.1 - 3" + d3-time-format "2 - 4" + +d3-shape@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.2.0.tgz#a1a839cbd9ba45f28674c69d7f855bcf91dfc6a5" + integrity sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA== + dependencies: + d3-path "^3.1.0" + +"d3-time-format@2 - 4": + version "4.1.0" + resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a" + integrity sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg== + dependencies: + d3-time "1 - 3" + +"d3-time@1 - 3", "d3-time@2.1.1 - 3", d3-time@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.1.0.tgz#9310db56e992e3c0175e1ef385e545e48a9bb5c7" + integrity sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q== + dependencies: + d3-array "2 - 3" + +d3-timer@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0" + integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA== + damerau-levenshtein@^1.0.8: version "1.0.8" resolved "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz" @@ -7691,6 +7845,11 @@ decamelize@^1.1.0, decamelize@^1.2.0: resolved "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz" integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== +decimal.js-light@^2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz#134fd32508f19e208f4fb2f8dac0d2626a867934" + integrity sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg== + decimal.js@^10.4.2: version "10.6.0" resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.6.0.tgz#e649a43e3ab953a72192ff5983865e509f37ed9a" @@ -8551,6 +8710,11 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" +es-toolkit@^1.39.3: + version "1.48.1" + resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.48.1.tgz#4e8a7c3b0fe3a80f4d640934c8552238f25430c7" + integrity sha512-wfnXlwd5I75eXRtdD2vuEs50xHHESECDsGD7yiQnfFVNoa5522NwXEbmgo98LfiukSQHs+mBM7/YG3qKJB9/mQ== + esbuild-plugin-alias@^0.2.1: version "0.2.1" resolved "https://registry.npmjs.org/esbuild-plugin-alias/-/esbuild-plugin-alias-0.2.1.tgz" @@ -8911,6 +9075,11 @@ eventemitter3@^4.0.7: resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz" integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== +eventemitter3@^5.0.1: + version "5.0.4" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.4.tgz#a86d66170433712dde814707ac52b5271ceb1feb" + integrity sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw== + events-listener@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/events-listener/-/events-listener-1.1.0.tgz" @@ -10690,6 +10859,16 @@ immediate@~3.0.5: resolved "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz" integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== +immer@^10.1.1: + version "10.2.0" + resolved "https://registry.yarnpkg.com/immer/-/immer-10.2.0.tgz#88a4ce06a1af64172d254b70f7cb04df51c871b1" + integrity sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw== + +immer@^11.0.0: + version "11.1.8" + resolved "https://registry.yarnpkg.com/immer/-/immer-11.1.8.tgz#08a6426f7019dbce8d6dff8c4a43bb25c550a575" + integrity sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA== + immer@^9.0.21: version "9.0.21" resolved "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz" @@ -10931,6 +11110,11 @@ internal-slot@^1.0.7: hasown "^2.0.0" side-channel "^1.0.4" +"internmap@1 - 2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009" + integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg== + invariant@^2.2.4: version "2.2.4" resolved "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz" @@ -15371,6 +15555,14 @@ react-query@^3.32.1, react-query@^3.39.3: broadcast-channel "^3.4.1" match-sorter "^6.0.2" +"react-redux@8.x.x || 9.x.x": + version "9.3.0" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-9.3.0.tgz#a30113bb6d95c0a715d54dda4308d450fca6ce09" + integrity sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g== + dependencies: + "@types/use-sync-external-store" "^0.0.6" + use-sync-external-store "^1.4.0" + react-redux@^8.0.2: version "8.1.3" resolved "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz" @@ -15540,6 +15732,23 @@ recast@^0.23.1, recast@^0.23.3: source-map "~0.6.1" tslib "^2.0.1" +recharts@^3.9.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/recharts/-/recharts-3.9.0.tgz#b78a4822d1b1805a7529a9e8a82f7a043e94c3c9" + integrity sha512-dCEcE9y20c8H2tkVeByrAXhhnBJk6/QLbxKmn+dJUptOfc5NMjwRh1jo0vZPRLD+5dMrHrP+hPEsfbGBMfnf5Q== + dependencies: + "@reduxjs/toolkit" "^1.9.0 || 2.x.x" + clsx "^2.1.1" + decimal.js-light "^2.5.1" + es-toolkit "^1.39.3" + eventemitter3 "^5.0.1" + immer "^10.1.1" + react-redux "8.x.x || 9.x.x" + reselect "5.2.0" + tiny-invariant "^1.3.3" + use-sync-external-store "^1.2.2" + victory-vendor "^37.0.2" + redent@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz" @@ -15572,6 +15781,11 @@ redux@^4.0.5, redux@^4.2.1: dependencies: "@babel/runtime" "^7.9.2" +redux@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/redux/-/redux-5.0.1.tgz#97fa26881ce5746500125585d5642c77b6e9447b" + integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w== + reflect.getprototypeof@^1.0.4: version "1.0.4" resolved "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz" @@ -15767,6 +15981,11 @@ requires-port@^1.0.0: resolved "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz" integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== +reselect@5.2.0, reselect@^5.1.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.2.0.tgz#f380ef7664332d26ea06c1cba04bdbbdcaa955f1" + integrity sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw== + reselect@^4.1.8: version "4.1.8" resolved "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz" @@ -17182,6 +17401,11 @@ tiny-invariant@^1.3.1: resolved "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz" integrity sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw== +tiny-invariant@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127" + integrity sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg== + tmp@^0.0.33: version "0.0.33" resolved "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz" @@ -17900,6 +18124,11 @@ use-sync-external-store@^1.0.0: resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz" integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== +use-sync-external-store@^1.2.2, use-sync-external-store@^1.4.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d" + integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== + usehooks-ts@^2.5.3: version "2.9.1" resolved "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-2.9.1.tgz" @@ -18006,6 +18235,26 @@ vfile@^5.0.0: unist-util-stringify-position "^3.0.0" vfile-message "^3.0.0" +victory-vendor@^37.0.2: + version "37.3.6" + resolved "https://registry.yarnpkg.com/victory-vendor/-/victory-vendor-37.3.6.tgz#401ac4b029a0b3d33e0cba8e8a1d765c487254da" + integrity sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ== + dependencies: + "@types/d3-array" "^3.0.3" + "@types/d3-ease" "^3.0.0" + "@types/d3-interpolate" "^3.0.1" + "@types/d3-scale" "^4.0.2" + "@types/d3-shape" "^3.1.0" + "@types/d3-time" "^3.0.0" + "@types/d3-timer" "^3.0.0" + d3-array "^3.1.6" + d3-ease "^3.0.1" + d3-interpolate "^3.0.1" + d3-scale "^4.0.2" + d3-shape "^3.1.0" + d3-time "^3.0.0" + d3-timer "^3.0.1" + vm-browserify@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz" From a9bc34cedc7fe82948785978cb379611ac710e43 Mon Sep 17 00:00:00 2001 From: janicelichtman Date: Fri, 10 Jul 2026 14:43:19 -0400 Subject: [PATCH 2/7] minor yarn lock changes --- package.json | 1 - yarn.lock | 5 ----- 2 files changed, 6 deletions(-) diff --git a/package.json b/package.json index 906a9f70e..abb31e6a6 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,6 @@ "@react-aria/ssr": "^3.2.0", "@react-aria/utils": "^3.13.1", "@reduxjs/toolkit": "^1.8.3", - "@swc/core-darwin-arm64": "^1.15.43", "@testing-library/dom": "^9.3.3", "autolinker": "^3.16.0", "awesome-debounce-promise": "^2.1.0", diff --git a/yarn.lock b/yarn.lock index 36661765d..10f7eead7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4333,11 +4333,6 @@ resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.100.tgz#f582c5bbc9c49506f728fc1d14dff33c2cc226d5" integrity sha512-XVWFsKe6ei+SsDbwmsuRkYck1SXRpO60Hioa4hoLwR8fxbA9eVp6enZtMxzVVMBi8ej5seZ4HZQeAWepbukiBw== -"@swc/core-darwin-arm64@^1.15.43": - version "1.15.43" - resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.43.tgz#386294f8427dde2df1a70dd0a5826d67af70e996" - integrity sha512-v1aVuvXdo/BHxJzco9V2xpHrvwWmhfS8t6gziY5wJxd+Z2h8AeJRnAwPD8itCDaGXVBwJ/CaKfxEzTkG0Va0OA== - "@swc/core-darwin-x64@1.15.21": version "1.15.21" resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.21.tgz#05ff28c00a7045d9760c847e19604fff02b6e3ea" From 758d259e49359667945316163841044f6a8320e4 Mon Sep 17 00:00:00 2001 From: janicelichtman Date: Tue, 14 Jul 2026 14:15:33 -0400 Subject: [PATCH 3/7] tweaking dispaly boxes on finance tab --- .../TabComponents/FinanceTab.tsx | 101 ++++++++++++++---- public/locales/en/legislators.json | 10 +- 2 files changed, 87 insertions(+), 24 deletions(-) diff --git a/components/LegislatorProfile/TabComponents/FinanceTab.tsx b/components/LegislatorProfile/TabComponents/FinanceTab.tsx index cc0926f7d..0392b789f 100644 --- a/components/LegislatorProfile/TabComponents/FinanceTab.tsx +++ b/components/LegislatorProfile/TabComponents/FinanceTab.tsx @@ -137,31 +137,88 @@ export function FinanceTab({ finance }: { finance?: MembersFinance }) { const total = slices.reduce((sum, s) => sum + s.value, 0) const nonContribution = finance.otherReceipts?.nonContribution?.amount ?? 0 + const cycleYears = Object.keys(finance.years ?? {}).map(Number) + const cycleYear = cycleYears.length + ? Math.max(...cycleYears) + : new Date().getFullYear() + + const totalSpentAsOf = finance.lastUpdated + ? finance.lastUpdated + .toDate() + .toLocaleDateString("en-US", { month: "short", year: "numeric" }) + : "" + + const statBoxes = [ + { + label: t("finance.stats.totalRaised"), + value: formatCurrency(finance.totalRaised), + subtitle: t("finance.stats.totalRaisedSubtitle", { + count: finance.contributorCount ?? 0 + }) + }, + { + label: t("finance.stats.totalSpent"), + value: formatCurrency(finance.totalSpent), + subtitle: t("finance.stats.totalSpentSubtitle", { date: totalSpentAsOf }) + }, + { + label: t("finance.stats.smallDonors"), + value: "—", + subtitle: t("finance.stats.smallDonorsSubtitle") + }, + { + label: t("finance.stats.cashOnHand"), + value: formatCurrency(finance.cashOnHand), + subtitle: t("finance.stats.cashOnHandSubtitle") + } + ] + return (
-
- {[ - { - label: t("finance.stats.totalRaised"), - value: finance.totalRaised - }, - { label: t("finance.stats.totalSpent"), value: finance.totalSpent }, - { label: t("finance.stats.cashOnHand"), value: finance.cashOnHand } - ].map(({ label, value }) => ( -
-
- {formatCurrency(value)} +
+ {t("finance.electionCycleHeading", { year: cycleYear })} +
+ +
+ {statBoxes.map(({ label, value, subtitle }) => ( +
+
+
+ {value} +
+
+ {label} +
+
+ {subtitle} +
-
{label}
))}
diff --git a/public/locales/en/legislators.json b/public/locales/en/legislators.json index 93c9bd742..5b8dc20e7 100644 --- a/public/locales/en/legislators.json +++ b/public/locales/en/legislators.json @@ -13,14 +13,20 @@ "union": "Unions & associations", "unitemized": "Small donors (unitemized)" }, - "influenceHeading": "Where the Influence Comes From — 2025–2026 Election Cycle", + "electionCycleHeading": "{{year}} Election Cycle", + "influenceHeading": "Contributions breakown", "noContributions": "No itemized contributions recorded yet.", "noData": "Finance data not yet available for this legislator.", "otherReceipts": "Other non-contribution receipts: {{amount}} (refunds and miscellaneous receipts — not included above)", "stats": { "cashOnHand": "Cash on Hand", + "cashOnHandSubtitle": "End of reporting period", + "smallDonors": "Small Donors", + "smallDonorsSubtitle": "Under $200 contributions", "totalRaised": "Total Raised", - "totalSpent": "Total Spent" + "totalRaisedSubtitle": "From {{count}} contributors", + "totalSpent": "Total Spent", + "totalSpentSubtitle": "As of {{date}}" } }, "fundsRaised": "Funds Raised", From e5bd5b48ce3863aa65e027f92551488b020fb0e6 Mon Sep 17 00:00:00 2001 From: janicelichtman Date: Tue, 14 Jul 2026 14:27:17 -0400 Subject: [PATCH 4/7] more minor tweaks on finance tab boxes --- components/LegislatorProfile/TabComponents/FinanceTab.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/components/LegislatorProfile/TabComponents/FinanceTab.tsx b/components/LegislatorProfile/TabComponents/FinanceTab.tsx index 0392b789f..cf7f0a24d 100644 --- a/components/LegislatorProfile/TabComponents/FinanceTab.tsx +++ b/components/LegislatorProfile/TabComponents/FinanceTab.tsx @@ -45,7 +45,6 @@ const FinancePieChart = dynamic( From 6c574ff3b155fe7216f52cd579a5abad2dcf51ee Mon Sep 17 00:00:00 2001 From: janicelichtman Date: Tue, 14 Jul 2026 18:15:55 -0400 Subject: [PATCH 5/7] removed pie chart --- .../TabComponents/FinanceTab.tsx | 179 +++++++------ package.json | 1 - public/locales/en/legislators.json | 10 +- yarn.lock | 244 ------------------ 4 files changed, 101 insertions(+), 333 deletions(-) diff --git a/components/LegislatorProfile/TabComponents/FinanceTab.tsx b/components/LegislatorProfile/TabComponents/FinanceTab.tsx index cf7f0a24d..3ae5e73c8 100644 --- a/components/LegislatorProfile/TabComponents/FinanceTab.tsx +++ b/components/LegislatorProfile/TabComponents/FinanceTab.tsx @@ -1,16 +1,6 @@ -import dynamic from "next/dynamic" import { useTranslation } from "next-i18next" import { MembersFinance } from "components/db/membersFinance" -const SLICE_COLORS = [ - "#1a3185", - "#4e6fcf", - "#7b9dd9", - "#a8c0e8", - "#2e7d32", - "#81c784" -] - function formatCurrency(n?: number | null): string { if (n == null || isNaN(n)) return "$0" return "$" + Math.round(n).toLocaleString() @@ -21,80 +11,11 @@ function formatPct(value: number, total: number): string { return Math.round((value / total) * 100) + "%" } -interface ChartSlice { +interface CategoryRow { name: string value: number } -interface FinancePieChartProps { - slices: ChartSlice[] - total: number -} - -const FinancePieChart = dynamic( - async () => { - const { PieChart, Pie, Cell, Tooltip } = await import("recharts") - - return function FinancePieChartInner({ - slices, - total - }: FinancePieChartProps) { - return ( -
- - - {slices.map((_, i) => ( - - ))} - - - typeof value === "number" - ? formatCurrency(value) - : String(value) - } - /> - - -
- {slices.map((s, i) => ( -
-
- {s.name} - - {formatPct(s.value, total)} - - - {formatCurrency(s.value)} - -
- ))} -
-
- ) - } - }, - { ssr: false } -) - export function FinanceTab({ finance }: { finance?: MembersFinance }) { const { t } = useTranslation("legislators") @@ -112,7 +33,7 @@ export function FinanceTab({ finance }: { finance?: MembersFinance }) { (finance.candidateFunds?.loans?.amount ?? 0) + (finance.candidateFunds?.contributions?.amount ?? 0) - const slices: ChartSlice[] = [ + const categories: CategoryRow[] = [ { name: t("finance.breakdown.individual"), value: finance.breakdown?.individual?.amount ?? 0 @@ -131,9 +52,11 @@ export function FinanceTab({ finance }: { finance?: MembersFinance }) { }, { name: t("finance.breakdown.candidateFunds"), value: candidateFundsTotal }, { name: t("finance.breakdown.inKind"), value: inKindTotal } - ].filter(s => s.value > 0) + ] + .filter(c => c.value > 0) + .sort((a, b) => b.value - a.value) - const total = slices.reduce((sum, s) => sum + s.value, 0) + const total = categories.reduce((sum, c) => sum + c.value, 0) const nonContribution = finance.otherReceipts?.nonContribution?.amount ?? 0 const cycleYears = Object.keys(finance.years ?? {}).map(Number) @@ -222,12 +145,96 @@ export function FinanceTab({ finance }: { finance?: MembersFinance }) { ))}
-
{t("finance.influenceHeading")}
+
+ {t("finance.breakdownHeading")} +
- {slices.length === 0 ? ( + {categories.length === 0 ? (

{t("finance.noContributions")}

) : ( - +
+
+ {t("finance.table.category")} +
+ + {t("finance.table.amount")} + + + {t("finance.table.share")} + +
+
+ + {categories.map((c, i) => ( +
+ {c.name} +
+ + {formatCurrency(c.value)} + + + + {formatPct(c.value, total)} + + +
+
+ ))} + +

+ {t("finance.source")} +

+
)} {nonContribution > 0 && ( diff --git a/package.json b/package.json index abb31e6a6..9a0329989 100644 --- a/package.json +++ b/package.json @@ -131,7 +131,6 @@ "react-query": "^3.39.3", "react-redux": "^8.0.2", "react-select": "^5.2.2", - "recharts": "^3.9.0", "redux-mock-store": "^1.5.4", "redux-thunk": "^3.1.0", "runtypes": "6.6.0", diff --git a/public/locales/en/legislators.json b/public/locales/en/legislators.json index 5b8dc20e7..7416f0f6e 100644 --- a/public/locales/en/legislators.json +++ b/public/locales/en/legislators.json @@ -11,13 +11,14 @@ "inKind": "In-kind contributions", "individual": "Individual donors", "union": "Unions & associations", - "unitemized": "Small donors (unitemized)" + "unitemized": "Small donors (under $50)" }, + "breakdownHeading": "Contributions Breakdown", "electionCycleHeading": "{{year}} Election Cycle", - "influenceHeading": "Contributions breakown", "noContributions": "No itemized contributions recorded yet.", "noData": "Finance data not yet available for this legislator.", "otherReceipts": "Other non-contribution receipts: {{amount}} (refunds and miscellaneous receipts — not included above)", + "source": "Source: MA OCPF · ocpf.us", "stats": { "cashOnHand": "Cash on Hand", "cashOnHandSubtitle": "End of reporting period", @@ -27,6 +28,11 @@ "totalRaisedSubtitle": "From {{count}} contributors", "totalSpent": "Total Spent", "totalSpentSubtitle": "As of {{date}}" + }, + "table": { + "amount": "Amount", + "category": "Category", + "share": "Share" } }, "fundsRaised": "Funds Raised", diff --git a/yarn.lock b/yarn.lock index 10f7eead7..2d0c0348b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3406,18 +3406,6 @@ redux-thunk "^2.4.2" reselect "^4.1.8" -"@reduxjs/toolkit@^1.9.0 || 2.x.x": - version "2.12.0" - resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-2.12.0.tgz#e62787503a38561e04bb8f39e29ca8db689590f9" - integrity sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw== - dependencies: - "@standard-schema/spec" "^1.0.0" - "@standard-schema/utils" "^0.3.0" - immer "^11.0.0" - redux "^5.0.1" - redux-thunk "^3.1.0" - reselect "^5.1.0" - "@remix-run/router@1.13.1": version "1.13.1" resolved "https://registry.npmjs.org/@remix-run/router/-/router-1.13.1.tgz" @@ -3496,16 +3484,6 @@ dependencies: "@sinonjs/commons" "^3.0.0" -"@standard-schema/spec@^1.0.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" - integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== - -"@standard-schema/utils@^0.3.0": - version "0.3.0" - resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b" - integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g== - "@storybook/addon-actions@7.6.4", "@storybook/addon-actions@^7.6.4": version "7.6.4" resolved "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-7.6.4.tgz" @@ -4654,57 +4632,6 @@ dependencies: "@types/node" "*" -"@types/d3-array@^3.0.3": - version "3.2.2" - resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.2.2.tgz#e02151464d02d4a1b44646d0fcdb93faf88fde8c" - integrity sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw== - -"@types/d3-color@*": - version "3.1.3" - resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.3.tgz#368c961a18de721da8200e80bf3943fb53136af2" - integrity sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A== - -"@types/d3-ease@^3.0.0": - version "3.0.2" - resolved "https://registry.yarnpkg.com/@types/d3-ease/-/d3-ease-3.0.2.tgz#e28db1bfbfa617076f7770dd1d9a48eaa3b6c51b" - integrity sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA== - -"@types/d3-interpolate@^3.0.1": - version "3.0.4" - resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c" - integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA== - dependencies: - "@types/d3-color" "*" - -"@types/d3-path@*": - version "3.1.1" - resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.1.1.tgz#f632b380c3aca1dba8e34aa049bcd6a4af23df8a" - integrity sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg== - -"@types/d3-scale@^4.0.2": - version "4.0.9" - resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.9.tgz#57a2f707242e6fe1de81ad7bfcccaaf606179afb" - integrity sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw== - dependencies: - "@types/d3-time" "*" - -"@types/d3-shape@^3.1.0": - version "3.1.8" - resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.8.tgz#d1516cc508753be06852cd06758e3bb54a22b0e3" - integrity sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w== - dependencies: - "@types/d3-path" "*" - -"@types/d3-time@*", "@types/d3-time@^3.0.0": - version "3.0.4" - resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.4.tgz#8472feecd639691450dd8000eb33edd444e1323f" - integrity sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g== - -"@types/d3-timer@^3.0.0": - version "3.0.2" - resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.2.tgz#70bbda77dc23aa727413e22e214afa3f0e852f70" - integrity sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw== - "@types/debounce-promise@^3.1.1": version "3.1.9" resolved "https://registry.npmjs.org/@types/debounce-promise/-/debounce-promise-3.1.9.tgz" @@ -5198,11 +5125,6 @@ resolved "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz" integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA== -"@types/use-sync-external-store@^0.0.6": - version "0.0.6" - resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz#60be8d21baab8c305132eb9cb912ed497852aadc" - integrity sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg== - "@types/uuid@^9.0.1": version "9.0.7" resolved "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.7.tgz" @@ -7656,77 +7578,6 @@ csv-parse@^5.0.4: resolved "https://registry.npmjs.org/csv-parse/-/csv-parse-5.5.3.tgz" integrity sha512-v0KW6C0qlZzoGjk6u5tLmVfyZxNgPGXZsWTXshpAgKVGmGXzaVWGdlCFxNx5iuzcXT/oJN1HHM9DZKwtAtYa+A== -"d3-array@2 - 3", "d3-array@2.10.0 - 3", d3-array@^3.1.6: - version "3.2.4" - resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.4.tgz#15fec33b237f97ac5d7c986dc77da273a8ed0bb5" - integrity sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg== - dependencies: - internmap "1 - 2" - -"d3-color@1 - 3": - version "3.1.0" - resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2" - integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA== - -d3-ease@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4" - integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w== - -"d3-format@1 - 3": - version "3.1.2" - resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.2.tgz#01fdb46b58beb1f55b10b42ad70b6e344d5eb2ae" - integrity sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg== - -"d3-interpolate@1.2.0 - 3", d3-interpolate@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d" - integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g== - dependencies: - d3-color "1 - 3" - -d3-path@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.1.0.tgz#22df939032fb5a71ae8b1800d61ddb7851c42526" - integrity sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ== - -d3-scale@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396" - integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ== - dependencies: - d3-array "2.10.0 - 3" - d3-format "1 - 3" - d3-interpolate "1.2.0 - 3" - d3-time "2.1.1 - 3" - d3-time-format "2 - 4" - -d3-shape@^3.1.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.2.0.tgz#a1a839cbd9ba45f28674c69d7f855bcf91dfc6a5" - integrity sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA== - dependencies: - d3-path "^3.1.0" - -"d3-time-format@2 - 4": - version "4.1.0" - resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a" - integrity sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg== - dependencies: - d3-time "1 - 3" - -"d3-time@1 - 3", "d3-time@2.1.1 - 3", d3-time@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.1.0.tgz#9310db56e992e3c0175e1ef385e545e48a9bb5c7" - integrity sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q== - dependencies: - d3-array "2 - 3" - -d3-timer@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0" - integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA== - damerau-levenshtein@^1.0.8: version "1.0.8" resolved "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz" @@ -7840,11 +7691,6 @@ decamelize@^1.1.0, decamelize@^1.2.0: resolved "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz" integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== -decimal.js-light@^2.5.1: - version "2.5.1" - resolved "https://registry.yarnpkg.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz#134fd32508f19e208f4fb2f8dac0d2626a867934" - integrity sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg== - decimal.js@^10.4.2: version "10.6.0" resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.6.0.tgz#e649a43e3ab953a72192ff5983865e509f37ed9a" @@ -8705,11 +8551,6 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" -es-toolkit@^1.39.3: - version "1.48.1" - resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.48.1.tgz#4e8a7c3b0fe3a80f4d640934c8552238f25430c7" - integrity sha512-wfnXlwd5I75eXRtdD2vuEs50xHHESECDsGD7yiQnfFVNoa5522NwXEbmgo98LfiukSQHs+mBM7/YG3qKJB9/mQ== - esbuild-plugin-alias@^0.2.1: version "0.2.1" resolved "https://registry.npmjs.org/esbuild-plugin-alias/-/esbuild-plugin-alias-0.2.1.tgz" @@ -9070,11 +8911,6 @@ eventemitter3@^4.0.7: resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz" integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== -eventemitter3@^5.0.1: - version "5.0.4" - resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.4.tgz#a86d66170433712dde814707ac52b5271ceb1feb" - integrity sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw== - events-listener@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/events-listener/-/events-listener-1.1.0.tgz" @@ -10854,16 +10690,6 @@ immediate@~3.0.5: resolved "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz" integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== -immer@^10.1.1: - version "10.2.0" - resolved "https://registry.yarnpkg.com/immer/-/immer-10.2.0.tgz#88a4ce06a1af64172d254b70f7cb04df51c871b1" - integrity sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw== - -immer@^11.0.0: - version "11.1.8" - resolved "https://registry.yarnpkg.com/immer/-/immer-11.1.8.tgz#08a6426f7019dbce8d6dff8c4a43bb25c550a575" - integrity sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA== - immer@^9.0.21: version "9.0.21" resolved "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz" @@ -11105,11 +10931,6 @@ internal-slot@^1.0.7: hasown "^2.0.0" side-channel "^1.0.4" -"internmap@1 - 2": - version "2.0.3" - resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009" - integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg== - invariant@^2.2.4: version "2.2.4" resolved "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz" @@ -15550,14 +15371,6 @@ react-query@^3.32.1, react-query@^3.39.3: broadcast-channel "^3.4.1" match-sorter "^6.0.2" -"react-redux@8.x.x || 9.x.x": - version "9.3.0" - resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-9.3.0.tgz#a30113bb6d95c0a715d54dda4308d450fca6ce09" - integrity sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g== - dependencies: - "@types/use-sync-external-store" "^0.0.6" - use-sync-external-store "^1.4.0" - react-redux@^8.0.2: version "8.1.3" resolved "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz" @@ -15727,23 +15540,6 @@ recast@^0.23.1, recast@^0.23.3: source-map "~0.6.1" tslib "^2.0.1" -recharts@^3.9.0: - version "3.9.0" - resolved "https://registry.yarnpkg.com/recharts/-/recharts-3.9.0.tgz#b78a4822d1b1805a7529a9e8a82f7a043e94c3c9" - integrity sha512-dCEcE9y20c8H2tkVeByrAXhhnBJk6/QLbxKmn+dJUptOfc5NMjwRh1jo0vZPRLD+5dMrHrP+hPEsfbGBMfnf5Q== - dependencies: - "@reduxjs/toolkit" "^1.9.0 || 2.x.x" - clsx "^2.1.1" - decimal.js-light "^2.5.1" - es-toolkit "^1.39.3" - eventemitter3 "^5.0.1" - immer "^10.1.1" - react-redux "8.x.x || 9.x.x" - reselect "5.2.0" - tiny-invariant "^1.3.3" - use-sync-external-store "^1.2.2" - victory-vendor "^37.0.2" - redent@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz" @@ -15776,11 +15572,6 @@ redux@^4.0.5, redux@^4.2.1: dependencies: "@babel/runtime" "^7.9.2" -redux@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/redux/-/redux-5.0.1.tgz#97fa26881ce5746500125585d5642c77b6e9447b" - integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w== - reflect.getprototypeof@^1.0.4: version "1.0.4" resolved "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz" @@ -15976,11 +15767,6 @@ requires-port@^1.0.0: resolved "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz" integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== -reselect@5.2.0, reselect@^5.1.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.2.0.tgz#f380ef7664332d26ea06c1cba04bdbbdcaa955f1" - integrity sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw== - reselect@^4.1.8: version "4.1.8" resolved "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz" @@ -17396,11 +17182,6 @@ tiny-invariant@^1.3.1: resolved "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz" integrity sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw== -tiny-invariant@^1.3.3: - version "1.3.3" - resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127" - integrity sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg== - tmp@^0.0.33: version "0.0.33" resolved "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz" @@ -18119,11 +17900,6 @@ use-sync-external-store@^1.0.0: resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz" integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== -use-sync-external-store@^1.2.2, use-sync-external-store@^1.4.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d" - integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== - usehooks-ts@^2.5.3: version "2.9.1" resolved "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-2.9.1.tgz" @@ -18230,26 +18006,6 @@ vfile@^5.0.0: unist-util-stringify-position "^3.0.0" vfile-message "^3.0.0" -victory-vendor@^37.0.2: - version "37.3.6" - resolved "https://registry.yarnpkg.com/victory-vendor/-/victory-vendor-37.3.6.tgz#401ac4b029a0b3d33e0cba8e8a1d765c487254da" - integrity sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ== - dependencies: - "@types/d3-array" "^3.0.3" - "@types/d3-ease" "^3.0.0" - "@types/d3-interpolate" "^3.0.1" - "@types/d3-scale" "^4.0.2" - "@types/d3-shape" "^3.1.0" - "@types/d3-time" "^3.0.0" - "@types/d3-timer" "^3.0.0" - d3-array "^3.1.6" - d3-ease "^3.0.1" - d3-interpolate "^3.0.1" - d3-scale "^4.0.2" - d3-shape "^3.1.0" - d3-time "^3.0.0" - d3-timer "^3.0.1" - vm-browserify@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz" From 262d176c244c638113f02b11e6886724460a8c18 Mon Sep 17 00:00:00 2001 From: janicelichtman Date: Tue, 14 Jul 2026 18:50:05 -0400 Subject: [PATCH 6/7] cleaned up language in finance tab boxes --- .../TabComponents/FinanceTab.tsx | 26 +++++++++++++++---- public/locales/en/legislators.json | 3 ++- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/components/LegislatorProfile/TabComponents/FinanceTab.tsx b/components/LegislatorProfile/TabComponents/FinanceTab.tsx index 3ae5e73c8..3027b1a1f 100644 --- a/components/LegislatorProfile/TabComponents/FinanceTab.tsx +++ b/components/LegislatorProfile/TabComponents/FinanceTab.tsx @@ -76,22 +76,26 @@ export function FinanceTab({ finance }: { finance?: MembersFinance }) { value: formatCurrency(finance.totalRaised), subtitle: t("finance.stats.totalRaisedSubtitle", { count: finance.contributorCount ?? 0 - }) + }), + footnote: t("finance.stats.totalRaisedFootnote") }, { label: t("finance.stats.totalSpent"), value: formatCurrency(finance.totalSpent), - subtitle: t("finance.stats.totalSpentSubtitle", { date: totalSpentAsOf }) + subtitle: t("finance.stats.totalSpentSubtitle", { date: totalSpentAsOf }), + footnote: undefined as string | undefined }, { label: t("finance.stats.smallDonors"), value: "—", - subtitle: t("finance.stats.smallDonorsSubtitle") + subtitle: t("finance.stats.smallDonorsSubtitle"), + footnote: undefined as string | undefined }, { label: t("finance.stats.cashOnHand"), value: formatCurrency(finance.cashOnHand), - subtitle: t("finance.stats.cashOnHandSubtitle") + subtitle: t("finance.stats.cashOnHandSubtitle"), + footnote: undefined as string | undefined } ] @@ -111,7 +115,7 @@ export function FinanceTab({ finance }: { finance?: MembersFinance }) {
- {statBoxes.map(({ label, value, subtitle }) => ( + {statBoxes.map(({ label, value, subtitle, footnote }) => (
{subtitle}
+ {footnote && ( +
+ {footnote} +
+ )}
))} diff --git a/public/locales/en/legislators.json b/public/locales/en/legislators.json index 7416f0f6e..4362e82c5 100644 --- a/public/locales/en/legislators.json +++ b/public/locales/en/legislators.json @@ -24,7 +24,8 @@ "cashOnHandSubtitle": "End of reporting period", "smallDonors": "Small Donors", "smallDonorsSubtitle": "Under $200 contributions", - "totalRaised": "Total Raised", + "totalRaised": "Total Raised*", + "totalRaisedFootnote": "*after processing fees", "totalRaisedSubtitle": "From {{count}} contributors", "totalSpent": "Total Spent", "totalSpentSubtitle": "As of {{date}}" From becbe2d43c588fec1e18539f9c0249fcfbfe0f6a Mon Sep 17 00:00:00 2001 From: janicelichtman Date: Tue, 14 Jul 2026 18:57:49 -0400 Subject: [PATCH 7/7] removed filter for only scraping a single member --- functions/src/ocpf/scrapeOcpfFinance.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/functions/src/ocpf/scrapeOcpfFinance.ts b/functions/src/ocpf/scrapeOcpfFinance.ts index 478b40339..4d23e27fb 100644 --- a/functions/src/ocpf/scrapeOcpfFinance.ts +++ b/functions/src/ocpf/scrapeOcpfFinance.ts @@ -17,8 +17,8 @@ import { MembersFinanceYearData } from "./types" -// Set to null to run all members. Validate single-member output first. -const TEST_CPF_ID: number | null = 16883 // Rebecca L. Rausch, RLR0 +// Set to null to run all members. Use individual CPF_ID to validate single-member output. +const TEST_CPF_ID: number | null = null // For testing, can use 16883, Rebecca L. Rausch, RLR0 const OCPF_BASE_URL = "https://ocpf2.blob.core.windows.net/downloads/data2"