From 14ca8cb4beb74fc095dd995160e80f6ea942b13d Mon Sep 17 00:00:00 2001 From: John Carroll Date: Sun, 17 May 2026 17:54:42 -0400 Subject: [PATCH 1/5] template and initial js for election spending summary --- fec/data/templates/elections.jinja | 3 + .../election-data-and-compliance-tab.jinja | 1 + .../elections/raising-and-spending.jinja | 50 +++++ .../partials/elections/sidebar-nav.jinja | 1 + fec/fec/static/js/pages/election-summary.js | 189 ++++++++++++++++++ 5 files changed, 244 insertions(+) create mode 100644 fec/data/templates/partials/elections/raising-and-spending.jinja create mode 100644 fec/fec/static/js/pages/election-summary.js diff --git a/fec/data/templates/elections.jinja b/fec/data/templates/elections.jinja index 853cccfe50..f1857cac4a 100644 --- a/fec/data/templates/elections.jinja +++ b/fec/data/templates/elections.jinja @@ -15,6 +15,7 @@ {% block css %} + {% endblock %} {% block body %} {{ header.header(title, crumb) }} @@ -56,6 +57,7 @@ election_full: true, duration: '{{ election_duration }}', office: '{{ office }}', + office_code: '{{ office_code }}', state: '{{ state or '' }}', stateFull: '{{ state_full or '' }}', district: '{{ district or '' }}' @@ -63,6 +65,7 @@ }; +{{ tags_for_js_chunks('election-summary.js', '')|safe }} {#- data-init and global chunks are loaded by main.jinja -#} {{ tags_for_js_chunks('elections.js', '')|safe }} {% endblock %} diff --git a/fec/data/templates/partials/elections/election-data-and-compliance-tab.jinja b/fec/data/templates/partials/elections/election-data-and-compliance-tab.jinja index 5d82d57fba..cc17292940 100644 --- a/fec/data/templates/partials/elections/election-data-and-compliance-tab.jinja +++ b/fec/data/templates/partials/elections/election-data-and-compliance-tab.jinja @@ -33,6 +33,7 @@
{{ select.election_cycle_select(cycles, cycle, 'summary') }} + {% include 'partials/elections/raising-and-spending.jinja' %}

Candidate financial totals

diff --git a/fec/data/templates/partials/elections/raising-and-spending.jinja b/fec/data/templates/partials/elections/raising-and-spending.jinja new file mode 100644 index 0000000000..ccb088de10 --- /dev/null +++ b/fec/data/templates/partials/elections/raising-and-spending.jinja @@ -0,0 +1,50 @@ +{# used inside fec/data/templates/house-senate-overview.jinja #} +{% import 'macros/null.jinja' as null %} +{% import 'macros/disclaimer.jinja' as disclaimer %} +{% import 'macros/cycle-select.jinja' as select %} +
+

Raising and spending summary

+
+
+
+
+
+
+
+

$1,234,567,890

+

raised by candidates

+
+ +
+
+
+
+
+
+

$1,234,567,890

+

spent by candidates

+
+ +
+
+
+
+
+
+

$1,234,567,890

+

cash on hand for candidates

+
+ +
+
+
+
+
+
+ Newly filed summary data may not appear for up to 48 hours. Money raised includes both itemized and unitemized totals. Independent expenditure totals are combined for all candidates, exclude 24- and 48-hour reports, and may take 30 days or more to appear. +
+
+
+
+
+
\ No newline at end of file diff --git a/fec/data/templates/partials/elections/sidebar-nav.jinja b/fec/data/templates/partials/elections/sidebar-nav.jinja index 4a8ea632b6..ea09b298d2 100644 --- a/fec/data/templates/partials/elections/sidebar-nav.jinja +++ b/fec/data/templates/partials/elections/sidebar-nav.jinja @@ -12,6 +12,7 @@ Election data and reporting deadlines
    +
  • Raising and spending summary
  • Candidate financial totals
  • Individual contributions to candidates
  • Independent expenditures
  • diff --git a/fec/fec/static/js/pages/election-summary.js b/fec/fec/static/js/pages/election-summary.js new file mode 100644 index 0000000000..1d7ca0f409 --- /dev/null +++ b/fec/fec/static/js/pages/election-summary.js @@ -0,0 +1,189 @@ +/** + * + */ +import FECContainerQuery from '../modules/container-queries.js'; +import PartyMoneyBars from '../modules/party-money-bars.js'; + +/** + * Runs the Summary tab at /data/elections/house/ and /data/elections/senate/ + * @class + * @property {HTMLInputElement} cycleSelector - the + */ +export default function ElectionSummary() { + this.tabPanel = document.querySelector('#election-summary'); + this.cycleSelector = document.querySelector('.cycle-select #summary-cycle'); + + this.init(); + this.startLoadingData(this.cycleSelector.value); +} + +/** + * Gets this instance built and working + */ +ElectionSummary.prototype.init = function() { + this.cycleSelector.addEventListener('change', this.handleCycleChange.bind(this)); + + const theFigures = document.querySelectorAll('#election-summary figure'); + theFigures.forEach(el => { + // const elId = el.id; + new PartyMoneyBars( + `#${el.id} .parties-wrapper`, + `#${el.id} .js-value-large`, + { + eventId: el.dataset.eventFieldId, + figureGroupClasses: 'grid--flex grid--3-wide', + figureClasses: 'grid__item' + } + ); + }); + + new FECContainerQuery('#election-summary', '#summary'); +}; + +/** + * Takes an election year, deactivates cycleSelect, and starts the data load + * @param {number|string} electionYear - Which election year / cycle to load + */ + ElectionSummary.prototype.startLoadingData = function(electionYear) { + // Only do anything if electionYear is an year between 1970 and 2050 + //if (parseInt(electionYear) && electionYear >= 1970 && electionYear <= 2050 && electionYear % 2 === 0) { + //this.deactivateInput(); + + const instance = this; + + const url_params = { + aggregate_by: 'office-state-district', + api_key: window.API_KEY_PUBLIC, + election_full: true, + election_year: '', + is_active_candidate: true, + office: '', + district: '', + state: '', + page: 1, + per_page: 10, + sort_hide_null: false, + sort_null_only: false, + sort_nulls_last: false + }; + + let theURL = `${window.API_LOCATION}/${window.API_VERSION}/candidates/totals/aggregates/?`; + + url_params.election_year = electionYear; + url_params.office = window.context.election.office_code.toUpperCase(); + url_params.state = window.context.election.state; + url_params.district = window.context.election.district; + for (let n in url_params) { + theURL += `${n}=${url_params[n]}&`; + } + // Object to hold totals plus by-party totals + const allTotals = {}; + fetch( + theURL, + { cache: 'no-cache', mode: 'cors' } + ) + .then(response => response.json()) + .then(data => { + //instance.handleDataLoaded(data.results); + // Get the totals + allTotals['total'] = data.results; + }) + .catch(() => { + // console.log(`The fetch failed because: ${error}`); + }); + + const partys = ['DEM', 'REP', 'OTHER']; + + for (const party of partys) { + fetch( + theURL +`party=${party}`, + { cache: 'no-cache', mode: 'cors' } + ) + .then(response => response.json()) + .then(data => { + // Add the the by-party totals + allTotals[party] = data.results; + // TODO: Some way to just run this once instead of 3 times (for each iteration) + instance.handleDataLoaded(allTotals); + }) + .catch(() => { + // console.log(`The fetch failed because: ${error}`); + }); + } + +//} //end if + +}; + +/** + * Removes functionality from the input/select assigned to this.cycleSelector + */ +ElectionSummary.prototype.deactivateInput = function() { + this.cycleSelector.setAttribute('aria-disabled', 'true'); + this.cycleSelector.setAttribute('disabled', 'true'); +}; + +/** + * Restores functionality from the input/select assigned to this.cycleSelector + */ +ElectionSummary.prototype.reactivateInput = function() { + this.cycleSelector.removeAttribute('aria-disabled'); + this.cycleSelector.removeAttribute('disabled'); +}; + +/** + * Handles the change/input event from this.cycleSelector + * @param {Event} e - Change event + * @param {number} e.target.value - A valid 4-digit integer for an even/election year + */ +ElectionSummary.prototype.handleCycleChange = function (e) { + this.startLoadingData(e.target.value); +}; + +/** + * Takes the results, parses them from parties divisions to type combinations, then reactivates cycleSelect. + * (Takes [{dem numbers}, {rep numbers}, {other numbers}] and converts to [{receipts: {total, dem numbers, rep numbers, other numbers}}]) + * @param {Object} results - response.results from the api + */ + +ElectionSummary.prototype.handleDataLoaded = function(results) { + const usefulResults = { + total_cash_on_hand_end_period: { total: 0, DEM: 0, REP: 0, Other: 0 }, + total_debts_owed_by_committee: { total: 0, DEM: 0, REP: 0, Other: 0 }, + total_disbursements: { total: 0, DEM: 0, REP: 0, Other: 0 }, + total_individual_itemized_contributions: { total: 0, DEM: 0, REP: 0, Other: 0 }, + total_other_political_committee_contributions: { total: 0, DEM: 0, REP: 0, Other: 0 }, + total_receipts: { total: 0, DEM: 0, REP: 0, Other: 0 }, + total_transfers_from_other_authorized_committee: { total: 0, DEM: 0, REP: 0, Other: 0 } + }; + + for (const key in results) { + for (const val of Object.values(results[key])) { + for (const item in val) { + if (item.indexOf('total_') === 0) { + usefulResults[item][key] = results[key][0][item]; + } + } + } + } + + for (let key in usefulResults) { + const newEvent = new CustomEvent('fec_data_refresh', { + bubbles: true, + detail: { + id: key, + value: usefulResults[key] + } + }); + this.tabPanel.dispatchEvent(newEvent); + } + + this.reactivateInput(); +}; + +/** + * Now let's wait for the page to load and start making elements work + */ +window.addEventListener('load', () => { + new ElectionSummary(); +}); From 5732b525a96e92936dfc02b4a782ef957c83390a Mon Sep 17 00:00:00 2001 From: John Carroll Date: Mon, 20 Jul 2026 17:45:33 -0400 Subject: [PATCH 2/5] change comment --- fec/fec/static/js/pages/election-summary.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fec/fec/static/js/pages/election-summary.js b/fec/fec/static/js/pages/election-summary.js index 1d7ca0f409..71a32cdac2 100644 --- a/fec/fec/static/js/pages/election-summary.js +++ b/fec/fec/static/js/pages/election-summary.js @@ -103,7 +103,7 @@ ElectionSummary.prototype.init = function() { .then(data => { // Add the the by-party totals allTotals[party] = data.results; - // TODO: Some way to just run this once instead of 3 times (for each iteration) + // TODO: async way to just run this once instead of 3 times (for each iteration) instance.handleDataLoaded(allTotals); }) .catch(() => { From 2d200876b4d59b40a54fafc1f8283b4e9fed6577 Mon Sep 17 00:00:00 2001 From: John Carroll Date: Tue, 6 Oct 2026 16:07:42 -0400 Subject: [PATCH 3/5] fix overview other, ind-exp money bars --- .../elections/raising-and-spending.jinja | 93 ++++----- fec/fec/static/js/modules/helpers.js | 17 ++ .../static/js/modules/ind-exp-money-bars.js | 152 +++++++++++++++ fec/fec/static/js/modules/party-money-bars.js | 16 +- fec/fec/static/js/pages/election-summary.js | 177 ++++++++++++------ .../js/pages/house-senate-overview-summary.js | 6 +- fec/fec/static/scss/_fonts.scss | 3 + .../scss/widgets/house-senate-overview.scss | 1 + .../static/scss/widgets/party-money-bars.scss | 71 ++++++- 9 files changed, 424 insertions(+), 112 deletions(-) create mode 100644 fec/fec/static/js/modules/ind-exp-money-bars.js diff --git a/fec/data/templates/partials/elections/raising-and-spending.jinja b/fec/data/templates/partials/elections/raising-and-spending.jinja index ccb088de10..ca9b9218dc 100644 --- a/fec/data/templates/partials/elections/raising-and-spending.jinja +++ b/fec/data/templates/partials/elections/raising-and-spending.jinja @@ -2,49 +2,54 @@ {% import 'macros/null.jinja' as null %} {% import 'macros/disclaimer.jinja' as disclaimer %} {% import 'macros/cycle-select.jinja' as select %} -
    -

    Raising and spending summary

    -
    -
    -
    -
    -
    -
    -
    -

    $1,234,567,890

    -

    raised by candidates

    -
    - -
    -
    -
    -
    -
    -
    -

    $1,234,567,890

    -

    spent by candidates

    -
    - -
    -
    -
    -
    -
    -
    -

    $1,234,567,890

    -

    cash on hand for candidates

    -
    - -
    -
    -
    + +
    +

    Raising and spending summary

    +
    +
    +
    +
    +
    +
    +
    +

    $1,234,567,890

    +

    raised by candidates

    -
    -
    - Newly filed summary data may not appear for up to 48 hours. Money raised includes both itemized and unitemized totals. Independent expenditure totals are combined for all candidates, exclude 24- and 48-hour reports, and may take 30 days or more to appear. -
    -
    -
    + +
    +
    +
    +
    +
    +
    +

    $1,234,567,890

    +

    spent by candidates

    -
    -
    \ No newline at end of file + +
    + +
    + +
    +
    +
    +

    $1,234,567,890

    +

    independent expenditures

    +
    + +
    +
    +
    +
    +
    + +
    +
    + Newly filed summary data may not appear for up to 48 hours. Money raised includes both itemized and unitemized totals. Independent expenditure totals are combined for all candidates, exclude 24- and 48-hour reports, and may take 30 days or more to appear. +
    +
    +
    +
    +
    + +
diff --git a/fec/fec/static/js/modules/helpers.js b/fec/fec/static/js/modules/helpers.js index f6aa0c25bc..6a07036454 100644 --- a/fec/fec/static/js/modules/helpers.js +++ b/fec/fec/static/js/modules/helpers.js @@ -164,6 +164,23 @@ export function dollar(value) { } } +export const compactCurrency = (value) => { + const absValue = Math.abs(value); + + // Choose fraction digits dynamically based on size + // Millions get 1 decimal place; everything else (thousands, hundreds) gets 0 + const fractionDigits = absValue >= 1_000_000 ? 1 : 0; + + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + notation: 'compact', + maximumFractionDigits: fractionDigits + }).format(value).toLowerCase(); +}; + +Handlebars.registerHelper('compactCurrency', compactCurrency); + export const numberFormatter = function(number) { return numeral(number).format('0,0'); }; diff --git a/fec/fec/static/js/modules/ind-exp-money-bars.js b/fec/fec/static/js/modules/ind-exp-money-bars.js new file mode 100644 index 0000000000..78fe9fcfb5 --- /dev/null +++ b/fec/fec/static/js/modules/ind-exp-money-bars.js @@ -0,0 +1,152 @@ +/** + * Handles the creation and animation for the animated three-row money comparison bars + */ + +import { compactCurrency } from './helpers.js'; + +/** + * Quick lookup for converting api results to sometimes-abbreviated and punctuated versions + */ +const partyAbbrevs = { + S: 'Support.', + O: 'Oppose.', + Others: 'Other' +}; + +/** + * The defaults for a new IeMoneyBars, overridden by opts constructor argument + */ +const defaultSettings = { + animateChanges: true, // TODO: this isn't being read yet + currencyLabel: 'US Dollars', // TODO: is this needed? Maybe if we'd like to override it sometimes? + eventId: null, + figureClasses: '', // TODO: make this work? + figureGroupClasses: '', // TODO: make this work? + initValues: [ + { min: 0, max: 100, value: 50, party: 'S' }, + { min: 0, max: 100, value: 50, party: 'O' }, + { min: 0, max: 100, value: 50, party: 'Others' } + ] +}; + +/** + * @class + * @param {string} putMetersHereSelector - How to find the HTMLElement that will be used for this. + * @param {string} [putTotalHereSelector] - Where to put the total. + * @param {Object} [opts] - Options to override defaults. + * @property {HTMLElement} metersHolder - The HTMLElement parent where the meters should go. + * @property {HTMLElement} totalElm - The HTMLElement whose innerText should be used to display the total $. + * @property {Object} settings - Combination of settings from opts overriding defaultSettings. + * @returns {IeMoneyBars} New instance of IeyMoneyBars. + */ +export default function IeMoneyBars(putMetersHereSelector, putTotalHereSelector, opts = {}) { + this.metersHolder = document.querySelector(putMetersHereSelector); + this.totalElm = putTotalHereSelector.length > 1 ? document.querySelector(putTotalHereSelector) : null; + this.settings = Object.assign({}, defaultSettings, opts); + + this.init(); + return this; +} + +/** + * Does all of the setup work that doesn't involve constructor arguments + */ +IeMoneyBars.prototype.init = function() { + + // Build elements + // Add the parties holder to this.metersHolder + this.metersHolder.innerHTML = partiesHolderTemplate; + // Then add three meters to the parties holder + const partiesHolder = this.metersHolder.querySelector('.js-parties-holder'); + for (let i = 0; i < 3; i++) { + partiesHolder.innerHTML += meterRowTemplate; + } + + const meters = this.metersHolder.querySelectorAll('meter'); + + // Let's use an ID so we can more easily update meters' labels. + // What ID can we give these meters? Base it on the first parent with an id. + // Use that ID and make its name a bit more custom for this class + const metersIdBase = this.metersHolder.closest('[id]').id; + meters.forEach((meter, i) => { + const thisMetersId = `${metersIdBase}-party-money-meter-${i}`; + meter.setAttribute('id', thisMetersId); + if (this.settings.currencyLabel) meter.setAttribute('title', this.settings.currencyLabel); + const thisMetersParent = meter.closest('.js-meter-row'); + thisMetersParent.querySelector('.js-party-title').setAttribute('for', thisMetersId); + thisMetersParent.querySelector('.js-party-value').setAttribute('for', thisMetersId); + }); + + // Add listeners + document.body.addEventListener('fec_data_refresh', e => { + if (e.detail.id == this.settings.eventId) { + this.applyNewData(e.detail.value); + } + }); +}; + +/** + * Triggered by the body#fec_data_refresh event (if the event id matches this instance's id). + * Takes the new values, sorts them, and puts them in the right (ranked) elements + * @param {Object} newValObj - New value to represent. Expected structure is {total: 9.87, DEM: 7.65, REP: 5.43, Other: 3.21} + * @param {number} [newValObj.total] - Grand total, if we're showing that // TODO: make sure it appears and disappears as needed + * @param {number} newValObj.DEM - Value for Democrats + * @param {number} newValObj.REP - Value for Republicans + * @param {number} [newValObj.Others] - Value for Other parties // TODO: will this disappear if not included? Will it ever not be included? + */ +IeMoneyBars.prototype.applyNewData = function(newValObj) { + // If we're doing the total, it's at newValObj.total; + if (this.totalElm) { + if (newValObj.total) { + this.totalElm.textContent = compactCurrency(newValObj.total, true); + this.totalElm.dataset.totalValue = newValObj.total; + } else { + // TODO Handle if we have a total element but didn't get a new value for it + } + } + + // Sort the party values so the highest is on top + let rankedPartiesAndValues = [ + { party: 'S', value: newValObj.S }, + { party: 'O', value: newValObj.O }, + { party: 'Others', value: newValObj.Others } + ]; + // sort them based so the largest value is [0] (descending) + rankedPartiesAndValues.sort((a, b) => { + if (a.value < b.value) return 1; + else if (a.value > b.value) return -1; + else return 0; + }); + + const meters = this.metersHolder.querySelectorAll('meter'); + meters.forEach((meter, i) => { + meter.max = rankedPartiesAndValues[0].value; + meter.value = rankedPartiesAndValues[i].value; + meter.dataset.party = rankedPartiesAndValues[i].party.toUpperCase(); + // Update the meter's labels + meter.labels.forEach(label => { + if (label.classList.contains('js-party-title')) { + label.textContent = partyAbbrevs[rankedPartiesAndValues[i].party]; + } else if (label.classList.contains('js-party-value')) { + label.textContent = compactCurrency(meter.value, true); + } + }); + }); +}; + +/** + * The template for the wrapper where the individual js-meter-row elements will live + */ +const partiesHolderTemplate = `
`; + +/** + * Template for the rows/meters. There will generally be three of these inside one js-parties-holder + */ +const meterRowTemplate = ` +
+ + +
+ +
+
`; diff --git a/fec/fec/static/js/modules/party-money-bars.js b/fec/fec/static/js/modules/party-money-bars.js index eebab431da..f6a0ccfdbd 100644 --- a/fec/fec/static/js/modules/party-money-bars.js +++ b/fec/fec/static/js/modules/party-money-bars.js @@ -2,7 +2,7 @@ * Handles the creation and animation for the animated three-row money comparison bars */ -import { currency } from './helpers.js'; +import { compactCurrency } from './helpers.js'; /** * Quick lookup for converting api results to sometimes-abbreviated and punctuated versions @@ -10,7 +10,7 @@ import { currency } from './helpers.js'; const partyAbbrevs = { DEM: 'Dem.', REP: 'Rep.', - Other: 'Other' + OTHER: 'Other' }; /** @@ -25,7 +25,7 @@ const defaultSettings = { initValues: [ { min: 0, max: 100, value: 50, party: 'DEM' }, { min: 0, max: 100, value: 50, party: 'REP' }, - { min: 0, max: 100, value: 50, party: 'Other' } + { min: 0, max: 100, value: 50, party: 'OTHER' } ] }; @@ -98,7 +98,9 @@ PartyMoneyBars.prototype.applyNewData = function(newValObj) { // If we're doing the total, it's at newValObj.total; if (this.totalElm) { if (newValObj.total) { - this.totalElm.textContent = currency(newValObj.total, true); + this.totalElm.textContent = compactCurrency(newValObj.total); + this.totalElm.dataset.totalValue = newValObj.total; + //this.totalElm.textContent = this.actual_values ? currency(newValObj.total) : compactCurrency(newValObj.total); } else { // TODO Handle if we have a total element but didn't get a new value for it } @@ -108,7 +110,7 @@ PartyMoneyBars.prototype.applyNewData = function(newValObj) { let rankedPartiesAndValues = [ { party: 'DEM', value: newValObj.DEM }, { party: 'REP', value: newValObj.REP }, - { party: 'Other', value: newValObj.Other } + { party: 'OTHER', value: newValObj.OTHER } ]; // sort them based so the largest value is [0] (descending) rankedPartiesAndValues.sort((a, b) => { @@ -127,7 +129,9 @@ PartyMoneyBars.prototype.applyNewData = function(newValObj) { if (label.classList.contains('js-party-title')) { label.textContent = partyAbbrevs[rankedPartiesAndValues[i].party]; } else if (label.classList.contains('js-party-value')) { - label.textContent = currency(meter.value, true); + //label.textContent = this.actual_values ? currency(meter.value) :compactCurrency(meter.value); + label.textContent = compactCurrency(meter.value); + } }); }); diff --git a/fec/fec/static/js/pages/election-summary.js b/fec/fec/static/js/pages/election-summary.js index 71a32cdac2..48918af393 100644 --- a/fec/fec/static/js/pages/election-summary.js +++ b/fec/fec/static/js/pages/election-summary.js @@ -2,6 +2,8 @@ * */ import FECContainerQuery from '../modules/container-queries.js'; +//import { supportOppose } from '../modules/decoders.js'; +import IeMoneyBars from '../modules/ind-exp-money-bars.js'; import PartyMoneyBars from '../modules/party-money-bars.js'; /** @@ -23,9 +25,10 @@ export default function ElectionSummary() { ElectionSummary.prototype.init = function() { this.cycleSelector.addEventListener('change', this.handleCycleChange.bind(this)); - const theFigures = document.querySelectorAll('#election-summary figure'); - theFigures.forEach(el => { - // const elId = el.id; + //TODO, can I combibe the two below and use "new PartyMoneyBars" for both? + const theFigures = [...document.querySelectorAll('#election-summary figure')]; + // Get first two figures for raising and spending + theFigures.slice(0, 2).forEach(el => { new PartyMoneyBars( `#${el.id} .parties-wrapper`, `#${el.id} .js-value-large`, @@ -37,82 +40,136 @@ ElectionSummary.prototype.init = function() { ); }); + const theFiguresIe = document.querySelectorAll('#election-summary-ie figure'); + // Get the last figure for individual expenditures, loop is best approach even though there is only one + theFiguresIe.forEach(el => { + new IeMoneyBars( + `#${el.id} .parties-wrapper`, + `#${el.id} .js-value-large`, + { + eventId: el.dataset.eventFieldId, + figureGroupClasses: 'grid--flex grid--3-wide', + figureClasses: 'grid__item' + } + ); + }); + new FECContainerQuery('#election-summary', '#summary'); + }; /** * Takes an election year, deactivates cycleSelect, and starts the data load * @param {number|string} electionYear - Which election year / cycle to load */ - ElectionSummary.prototype.startLoadingData = function(electionYear) { - // Only do anything if electionYear is an year between 1970 and 2050 - //if (parseInt(electionYear) && electionYear >= 1970 && electionYear <= 2050 && electionYear % 2 === 0) { - //this.deactivateInput(); +ElectionSummary.prototype.startLoadingData = function(electionYear) { +// Only do anything if electionYear is an year between 1970 and 2050 +//if (parseInt(electionYear) && electionYear >= 1970 && electionYear <= 2050 && electionYear % 2 === 0) { + //this.deactivateInput(); const instance = this; - const url_params = { + const url_params = { aggregate_by: 'office-state-district', api_key: window.API_KEY_PUBLIC, election_full: true, - election_year: '', + election_year: electionYear, is_active_candidate: true, - office: '', - district: '', - state: '', - page: 1, - per_page: 10, + office: window.context.election.office_code.toUpperCase(), sort_hide_null: false, sort_null_only: false, - sort_nulls_last: false + sort_nulls_last: false, + page: 1, + per_page: 10 }; - let theURL = `${window.API_LOCATION}/${window.API_VERSION}/candidates/totals/aggregates/?`; + url_params.state = url_params.office == 'P' ? 'US' : window.context.election.state; + url_params.district = url_params.office == 'P' ? '00' : window.context.election.district; - url_params.election_year = electionYear; - url_params.office = window.context.election.office_code.toUpperCase(); - url_params.state = window.context.election.state; - url_params.district = window.context.election.district; + let theQstring = ''; for (let n in url_params) { - theURL += `${n}=${url_params[n]}&`; + theQstring += `${n}=${url_params[n]}&`; } - // Object to hold totals plus by-party totals + + const theURL = `${window.API_LOCATION}/${window.API_VERSION}/candidates/totals/aggregates/?${theQstring}`; + const ieQstring = theQstring.replace('election_year', 'cycle'); + const ieUrl = `${window.API_LOCATION}/${window.API_VERSION}/schedules/schedule_e/all_candidates/support_oppose_totals/?${ieQstring}`; + //const ieUrl = window.location.origin + '/static/az_suppose_totals.json'; + const allTotals = {}; - fetch( - theURL, - { cache: 'no-cache', mode: 'cors' } - ) - .then(response => response.json()) + const partys = ['DEM', 'REP', 'OTHER']; + + // Helper track to see when the candidate data block is completely ready + let candidatesRequestsFinished = 0; + + function checkAndRenderCandidates() { + candidatesRequestsFinished++; + // Once the 'total' call + all 3 party calls are done (4 requests total) + if (candidatesRequestsFinished === 4) { + instance.handleDataLoaded(allTotals); + } + } + + // 1. Fetch main totals + fetch(theURL, { cache: 'no-cache', mode: 'cors' }) + .then(res => res.json()) .then(data => { - //instance.handleDataLoaded(data.results); - // Get the totals allTotals['total'] = data.results; + checkAndRenderCandidates(); }) - .catch(() => { + //.catch(err => console.error('Error fetching grand total:', err)); + .catch(() => { // console.log(`The fetch failed because: ${error}`); }); - const partys = ['DEM', 'REP', 'OTHER']; + // 2. Fetch party totals + partys.forEach(party => { + fetch(`${theURL}party=${party}`, { cache: 'no-cache', mode: 'cors' }) + .then(res => res.json()) + .then(data => { + allTotals[party] = data.results; + checkAndRenderCandidates(); + }) + //.catch(err => console.error(`Error fetching ${party} total:`, err)); + .catch(() => { + // console.log(`The fetch failed because: ${error}`); + }); + }); - for (const party of partys) { - fetch( - theURL +`party=${party}`, - { cache: 'no-cache', mode: 'cors' } - ) - .then(response => response.json()) + // 3. Fetch the slow independent expenditures independently + // This will NOT block the rest of the page from loading + fetch(ieUrl, { cache: 'no-cache', mode: 'cors' }) + .then(res => res.json()) .then(data => { - // Add the the by-party totals - allTotals[party] = data.results; - // TODO: async way to just run this once instead of 3 times (for each iteration) - instance.handleDataLoaded(allTotals); + const result = data.results.reduce((accumulator, currentItem) => { + const amount = currentItem.ie_total; + const indicator = currentItem.support_oppose_indicator; + + accumulator.total += amount; + if (Object.hasOwn(accumulator, indicator)) { + accumulator[indicator] += amount; + } + return accumulator; + }, { total: 0, S: 0, O: 0, Others: 0 }); + + // Save to instance directly + instance.total_independent_expenditures = result; + allTotals['support_oppose'] = result; + + // Proactively update the UI with just the independent expenditure data once it arrives + if (typeof instance.handleIndependentExpendituresLoaded === 'function') { + instance.handleIndependentExpendituresLoaded(result); + } else { + // Fallback: re-trigger main loader if you don't have a specific sub-render function + instance.handleDataLoaded(allTotals); + } }) - .catch(() => { + //.catch(err => console.error('Error fetching independent expenditures:', err)); + .catch(() => { // console.log(`The fetch failed because: ${error}`); }); - } - -//} //end if +//}; END - if (parseInt(electionYear) && ... }; /** @@ -148,23 +205,26 @@ ElectionSummary.prototype.handleCycleChange = function (e) { ElectionSummary.prototype.handleDataLoaded = function(results) { const usefulResults = { - total_cash_on_hand_end_period: { total: 0, DEM: 0, REP: 0, Other: 0 }, - total_debts_owed_by_committee: { total: 0, DEM: 0, REP: 0, Other: 0 }, - total_disbursements: { total: 0, DEM: 0, REP: 0, Other: 0 }, - total_individual_itemized_contributions: { total: 0, DEM: 0, REP: 0, Other: 0 }, - total_other_political_committee_contributions: { total: 0, DEM: 0, REP: 0, Other: 0 }, - total_receipts: { total: 0, DEM: 0, REP: 0, Other: 0 }, - total_transfers_from_other_authorized_committee: { total: 0, DEM: 0, REP: 0, Other: 0 } + total_cash_on_hand_end_period: { total: 0, DEM: 0, REP: 0, OTHER: 0 }, + total_debts_owed_by_committee: { total: 0, DEM: 0, REP: 0, OTHER: 0 }, + total_disbursements: { total: 0, DEM: 0, REP: 0, OTHER: 0 }, + total_individual_itemized_contributions: { total: 0, DEM: 0, REP: 0, OTHER: 0 }, + total_other_political_committee_contributions: { total: 0, DEM: 0, REP: 0, OTHER: 0 }, + total_receipts: { total: 0, DEM: 0, REP: 0, OTHER: 0 }, + total_transfers_from_other_authorized_committee: { total: 0, DEM: 0, REP: 0, OTHER: 0 }, + total_independent_expenditures: this.total_independent_expenditures//{ total: 0, S: 0, A: 0, O: 0 }, }; - for (const key in results) { - for (const val of Object.values(results[key])) { - for (const item in val) { - if (item.indexOf('total_') === 0) { - usefulResults[item][key] = results[key][0][item]; + // Google's + for (const [key, value] of Object.entries(results)) { + for (const val of Object.values(value)) { + for (const item in val) { + if (item.startsWith('total_')) { + // Updated to use the local 'value' variable instead of reaching back to 'results[key]' + usefulResults[item][key] = value[0][item]; + } } } - } } for (let key in usefulResults) { @@ -179,6 +239,7 @@ ElectionSummary.prototype.handleDataLoaded = function(results) { } this.reactivateInput(); + }; /** diff --git a/fec/fec/static/js/pages/house-senate-overview-summary.js b/fec/fec/static/js/pages/house-senate-overview-summary.js index bf7199f446..7110878ffa 100644 --- a/fec/fec/static/js/pages/house-senate-overview-summary.js +++ b/fec/fec/static/js/pages/house-senate-overview-summary.js @@ -117,12 +117,12 @@ HSOverviewSummary.prototype.handleCycleChange = function (e) { */ HSOverviewSummary.prototype.handleDataLoaded = function(results) { const usefulResults = { - total_cash_on_hand_end_period: { total: 0, DEM: 0, REP: 0, Other: 0 }, + total_cash_on_hand_end_period: { total: 0, DEM: 0, REP: 0, OTHER: 0 }, total_debts_owed_by_committee: { total: 0, DEM: 0, REP: 0, Other: 0 }, - total_disbursements: { total: 0, DEM: 0, REP: 0, Other: 0 }, + total_disbursements: { total: 0, DEM: 0, REP: 0, OTHER: 0 }, total_individual_itemized_contributions: { total: 0, DEM: 0, REP: 0, Other: 0 }, total_other_political_committee_contributions: { total: 0, DEM: 0, REP: 0, Other: 0 }, - total_receipts: { total: 0, DEM: 0, REP: 0, Other: 0 }, + total_receipts: { total: 0, DEM: 0, REP: 0, OTHER: 0 }, total_transfers_from_other_authorized_committee: { total: 0, DEM: 0, REP: 0, Other: 0 } }; diff --git a/fec/fec/static/scss/_fonts.scss b/fec/fec/static/scss/_fonts.scss index 7c1b4cf139..1673fdedab 100644 --- a/fec/fec/static/scss/_fonts.scss +++ b/fec/fec/static/scss/_fonts.scss @@ -66,6 +66,8 @@ font-weight: bold; font-style: normal; font-display: swap; + unicode-range: U+0030-0039, U+002E, U+002C, U+0024; + } @font-face { @@ -96,4 +98,5 @@ font-weight: normal; font-style: normal; font-display: swap; + unicode-range: U+0030-0039, U+002E, U+002C, U+0024; } diff --git a/fec/fec/static/scss/widgets/house-senate-overview.scss b/fec/fec/static/scss/widgets/house-senate-overview.scss index f6a6f3358e..62ef172ed5 100644 --- a/fec/fec/static/scss/widgets/house-senate-overview.scss +++ b/fec/fec/static/scss/widgets/house-senate-overview.scss @@ -6,6 +6,7 @@ url('/static/fonts/fec-currencymono-regular.woff2') format('woff2'), url('/static/fonts/fec-currencymono-regular.woff') format('woff'), url('/static/fonts/fec-currencymono-regular.ttf') format('truetype'); + unicode-range: U+0030-0039, U+002E, U+002C, U+0024; } #contributions-over-time { diff --git a/fec/fec/static/scss/widgets/party-money-bars.scss b/fec/fec/static/scss/widgets/party-money-bars.scss index 474d714af6..62846d4e5d 100644 --- a/fec/fec/static/scss/widgets/party-money-bars.scss +++ b/fec/fec/static/scss/widgets/party-money-bars.scss @@ -6,6 +6,7 @@ $colorTextOnLight: #212121; $colorBarDef: #5b616b; $colorBarDem: #21adf7; $colorBarRep: #da3c18; +$colorBarExp: #36bdbb; $colorBarOtherOnDark: #d6d7d9; $colorBorderBottom: #e8e8e8; $fontFamilyMonospace: 'fec_currency_mono', 'karla', sans-serif; @@ -30,6 +31,8 @@ $fontWeightBold: 700; .js-parties-holder { grid-template-columns: 4em auto 25%; display: grid; + justify-content: start; + .js-meter-row { display: contents; @@ -134,7 +137,7 @@ $fontWeightBold: 700; position: absolute; border-right: thin solid $colorBarDef; } - &[data-party='DEM'] { + &[data-party='DEM'] { &::-webkit-meter-optimum-value { // note#1 background: $colorBarDem; @@ -164,6 +167,46 @@ $fontWeightBold: 700; border-right-color: $colorBarRep; } } + + &[data-party='S'], + &[data-party='O'], + &[data-party='OTHER'] { + &::-webkit-meter-optimum-value { + // note#1 + background: $colorBarExp; + transition: all 1s; + } + &:-moz-meter-optimum::-moz-meter-bar { + // note#1 + background: $colorBarExp; + transition: all 1s; + } + &::before { + border-right-color: $colorBarExp; + } + } + + + /* &#raising-spending-summary { + transition: none !important; + + ::-webkit-meter-inner-element { + display: block !important; + height: 100% !important; + box-sizing: border-box !important; + } + + ::-webkit-meter-bar { + display: block !important; + height: 100% !important; + } + + ::-webkit-meter-optimum-value { + transition: width 0.3s ease !important; + } + + } */ + } // Trying to get the widths to animate when the values change meter::-webkit-meter-suboptimum-value, @@ -178,6 +221,10 @@ $fontWeightBold: 700; } } + + + + footer { -ms-flex-align: baseline; align-items: baseline; @@ -406,3 +453,25 @@ $fontWeightBold: 700; } } } + +#raising-spending-summary { + transition: none !important; + + ::-webkit-meter-inner-element{ + display: block !important; + } + + ::-webkit-meter-bar { + margin-top: 2.7rem !important + + } + + ::-webkit-meter-optimum-value { + transition: width 1 ease !important; + } + + meter::before { + margin-top: .35rem + } + + } From 85d463f3b60de5445fe4743eb95cb61d9e450df1 Mon Sep 17 00:00:00 2001 From: John Carroll Date: Wed, 7 Oct 2026 10:34:03 -0400 Subject: [PATCH 4/5] test endpoint --- fec/fec/static/js/modules/helpers.js | 1 + fec/fec/static/js/pages/election-summary.js | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/fec/fec/static/js/modules/helpers.js b/fec/fec/static/js/modules/helpers.js index 6a07036454..586db4fc05 100644 --- a/fec/fec/static/js/modules/helpers.js +++ b/fec/fec/static/js/modules/helpers.js @@ -175,6 +175,7 @@ export const compactCurrency = (value) => { style: 'currency', currency: 'USD', notation: 'compact', + minimumFractionDigits: fractionDigits, maximumFractionDigits: fractionDigits }).format(value).toLowerCase(); }; diff --git a/fec/fec/static/js/pages/election-summary.js b/fec/fec/static/js/pages/election-summary.js index 48918af393..88b65961f3 100644 --- a/fec/fec/static/js/pages/election-summary.js +++ b/fec/fec/static/js/pages/election-summary.js @@ -92,8 +92,9 @@ ElectionSummary.prototype.startLoadingData = function(electionYear) { } const theURL = `${window.API_LOCATION}/${window.API_VERSION}/candidates/totals/aggregates/?${theQstring}`; - const ieQstring = theQstring.replace('election_year', 'cycle'); - const ieUrl = `${window.API_LOCATION}/${window.API_VERSION}/schedules/schedule_e/all_candidates/support_oppose_totals/?${ieQstring}`; + //const ieQstring = theQstring.replace('election_year', 'cycle'); + //const ieUrl = `${window.API_LOCATION}/${window.API_VERSION}/schedules/schedule_e/all_candidates/support_oppose_totals/?${ieQstring}`; + const ieUrl = `${window.API_LOCATION}/${window.API_VERSION}/candidates/totals/aggregates/?${theQstring}`; //const ieUrl = window.location.origin + '/static/az_suppose_totals.json'; const allTotals = {}; From cec637d8806113987cc19513b6490a4fd6b8c6aa Mon Sep 17 00:00:00 2001 From: John Carroll Date: Wed, 7 Oct 2026 11:26:46 -0400 Subject: [PATCH 5/5] restore ref to new endpoint --- fec/fec/static/js/pages/election-summary.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/fec/fec/static/js/pages/election-summary.js b/fec/fec/static/js/pages/election-summary.js index 88b65961f3..48918af393 100644 --- a/fec/fec/static/js/pages/election-summary.js +++ b/fec/fec/static/js/pages/election-summary.js @@ -92,9 +92,8 @@ ElectionSummary.prototype.startLoadingData = function(electionYear) { } const theURL = `${window.API_LOCATION}/${window.API_VERSION}/candidates/totals/aggregates/?${theQstring}`; - //const ieQstring = theQstring.replace('election_year', 'cycle'); - //const ieUrl = `${window.API_LOCATION}/${window.API_VERSION}/schedules/schedule_e/all_candidates/support_oppose_totals/?${ieQstring}`; - const ieUrl = `${window.API_LOCATION}/${window.API_VERSION}/candidates/totals/aggregates/?${theQstring}`; + const ieQstring = theQstring.replace('election_year', 'cycle'); + const ieUrl = `${window.API_LOCATION}/${window.API_VERSION}/schedules/schedule_e/all_candidates/support_oppose_totals/?${ieQstring}`; //const ieUrl = window.location.origin + '/static/az_suppose_totals.json'; const allTotals = {};