├── .nvmrc ├── .vscode ├── settings.json └── launch.json ├── functions ├── .gitignore ├── getLocalLegislatorsData │ ├── index.js │ ├── geolocate.js │ ├── findLocalLegislators.js │ └── certs │ │ └── intermediate.pem ├── package.json ├── index.js └── .eslintrc.json ├── .env.template ├── screenshot.png ├── src ├── images │ ├── dome.jpg │ ├── default-photo.jpg │ ├── scorecard-logo.png │ ├── progressive-mass-logo.png │ └── help.svg ├── pages │ ├── landing │ │ ├── images │ │ │ ├── cards.png │ │ │ ├── legislator.svg │ │ │ ├── collaboration.svg │ │ │ └── fine_print.svg │ │ ├── SearchInstructions.js │ │ ├── SearchForm.js │ │ └── index.js │ ├── index.js │ ├── 404.js │ └── all-legislators │ │ ├── SortButton │ │ ├── sort-arrows-selected.svg │ │ ├── sort-arrows-faded.svg │ │ └── index.js │ │ ├── index.js │ │ └── LegislatorList.js ├── styles │ ├── _sponsorships.scss │ ├── _variables.scss │ ├── _search.scss │ ├── _legislation-metadata.scss │ ├── index.scss │ ├── _my-legislators.scss │ ├── _header.scss │ ├── _widget-overrides.scss │ ├── _landing.scss │ ├── _legislator-metadata.scss │ ├── _bootstrap-overrides.scss │ └── _general.scss ├── components │ ├── ListPageHeading.js │ ├── layout │ │ ├── index.js │ │ ├── Nav.js │ │ └── Footer.js │ ├── legislator │ │ ├── tagMap.js │ │ ├── NewSenatorMessage.js │ │ ├── ErrorView.js │ │ ├── images │ │ │ └── survey.svg │ │ ├── YourLegislatorTabs.js │ │ ├── index.js │ │ ├── SessionTabs.js │ │ ├── ogImage.js │ │ ├── TermLayout.js │ │ ├── LegislatorMetadata.js │ │ ├── LegislatorTable.js │ │ ├── Rating.js │ │ ├── SponsorshipTable.js │ │ └── VoteTable.js │ ├── InfoPopover.js │ ├── progressBar │ │ ├── ProgressBar.js │ │ └── index.js │ ├── sponsorships │ │ ├── sponsorships.js │ │ └── index.js │ └── seo.js ├── utilities.js └── data │ └── senate_legislators.json ├── database.rules.json ├── .firebaserc ├── .prettierrc ├── temp.md ├── gatsby-ssr.js ├── gatsby-browser.js ├── firebase.json ├── processData ├── downloadAndBuildData │ ├── index.js │ ├── downloadGoogleSheets.js │ ├── updateLegislatorData.js │ └── buildLegislationData.js └── miscDataParsingScripts │ ├── addOpenStateIdsToVotes.js │ └── addOpenStatesIdsToCosponsorship.js ├── test └── downloadSocialImages.js ├── .eslintrc ├── .gitignore ├── .github └── workflows │ └── deploy.yml ├── gatsbyNodeHelper ├── buildSponsorshipCumulativeData.js ├── buildVoteCumulativeData.js └── index.js ├── README.md ├── gatsby-config.js ├── package.json ├── config └── webpackDevServer.config.js └── gatsby-node.js /.nvmrc: -------------------------------------------------------------------------------- 1 | 14.21.2 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /functions/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | GOOGLE_API_KEY=YOUR_KEY_GOES_HERE 2 | OPENSTATES_API_KEY=YOUR_KEY_GOES_HERE 3 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProgressiveMass/legislator-scorecard/HEAD/screenshot.png -------------------------------------------------------------------------------- /src/images/dome.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProgressiveMass/legislator-scorecard/HEAD/src/images/dome.jpg -------------------------------------------------------------------------------- /database.rules.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | ".read": "auth != null", 4 | ".write": "auth != null" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/images/default-photo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProgressiveMass/legislator-scorecard/HEAD/src/images/default-photo.jpg -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "prod": "progressive-mass", 4 | "staging": "progressive-mass-test" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/images/scorecard-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProgressiveMass/legislator-scorecard/HEAD/src/images/scorecard-logo.png -------------------------------------------------------------------------------- /src/pages/landing/images/cards.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProgressiveMass/legislator-scorecard/HEAD/src/pages/landing/images/cards.png -------------------------------------------------------------------------------- /src/images/progressive-mass-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ProgressiveMass/legislator-scorecard/HEAD/src/images/progressive-mass-logo.png -------------------------------------------------------------------------------- /src/pages/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Landing from './landing' 3 | const IndexPage = () => 4 | 5 | export default IndexPage 6 | -------------------------------------------------------------------------------- /src/styles/_sponsorships.scss: -------------------------------------------------------------------------------- 1 | .bill-list { 2 | @extend .module-container; 3 | @extend .module-container--full-width-on-small; 4 | background: $white; 5 | } -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "bracketSameLine": true, 6 | "jsxSingleQuote": true, 7 | "printWidth": 100 8 | } 9 | -------------------------------------------------------------------------------- /temp.md: -------------------------------------------------------------------------------- 1 | curl -X POST https://github.com/ProgressiveMass/legislator-scorecard/dispatches -H "Content-Type: application/json" --data '{"event_type": "deploy"}' -H 'Authorization:Bearer ' 2 | 3 | -------------------------------------------------------------------------------- /gatsby-ssr.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Implement Gatsby's SSR (Server Side Rendering) APIs in this file. 3 | * 4 | * See: https://www.gatsbyjs.org/docs/ssr-apis/ 5 | */ 6 | 7 | // You can delete this file if you're not using it 8 | -------------------------------------------------------------------------------- /functions/getLocalLegislatorsData/index.js: -------------------------------------------------------------------------------- 1 | const geolocate = require('./geolocate') 2 | const findLocalLegislators = require('./findLocalLegislators') 3 | 4 | module.exports = function(address) { 5 | return geolocate(address).then(findLocalLegislators) 6 | } 7 | -------------------------------------------------------------------------------- /gatsby-browser.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Implement Gatsby's Browser APIs in this file. 3 | * 4 | * See: https://www.gatsbyjs.org/docs/browser-apis/ 5 | */ 6 | 7 | // You can delete this file if you're not using it 8 | 9 | //load site-wide styles 10 | import './src/styles/index.scss' 11 | -------------------------------------------------------------------------------- /src/styles/_variables.scss: -------------------------------------------------------------------------------- 1 | $headings-font-family: 'Montserrat', -apple-system, BlinkMacSystemFont, 2 | 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 3 | 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; 4 | 5 | $primary: #157bfb; 6 | $secondary: #71b844 7 | -------------------------------------------------------------------------------- /src/components/ListPageHeading.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const ListPageHeading = ({ children }) => { 4 | return ( 5 |

6 | {children} 7 |

8 | ) 9 | } 10 | 11 | export default ListPageHeading 12 | -------------------------------------------------------------------------------- /src/components/layout/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Footer from './Footer' 3 | import Nav from './Nav' 4 | 5 | const Layout = ({ children }) => { 6 | return ( 7 |
8 |
12 | ) 13 | } 14 | 15 | export default Layout 16 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": { 3 | "rules": "database.rules.json" 4 | }, 5 | "hosting": { 6 | "public": "public", 7 | "rewrites": [ 8 | { 9 | "source": "**", 10 | "destination": "/index.html" 11 | } 12 | ] 13 | }, 14 | "functions": { 15 | "predeploy": [ 16 | "npm --prefix \"$RESOURCE_DIR\" run lint" 17 | ] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/pages/404.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'gatsby' 3 | 4 | import Layout from '../components/layout' 5 | import SEO from '../components/seo' 6 | 7 | const NotFoundPage = () => ( 8 | 9 | 10 |

NOT FOUND

11 | Take me home 12 |
13 | ) 14 | 15 | export default NotFoundPage 16 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "program": "${file}" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /src/styles/_search.scss: -------------------------------------------------------------------------------- 1 | .search-form { 2 | margin: auto; 3 | color: $body-color; 4 | box-shadow: 0; 5 | border-radius: 6px; 6 | @include media-breakpoint-down(md) { 7 | padding: 2rem 1rem; 8 | margin-bottom: 3rem; 9 | } 10 | @include media-breakpoint-up(md) { 11 | padding: 2rem; 12 | } 13 | } 14 | 15 | .search-form .btn-block { 16 | @include media-breakpoint-up(lg) { 17 | font-size: 1.2rem; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/styles/_legislation-metadata.scss: -------------------------------------------------------------------------------- 1 | .pltfm-logo-container { 2 | margin-right: 2rem; 3 | @include media-breakpoint-down(sm) { 4 | display: none; 5 | } 6 | } 7 | 8 | .pltfm-logo { 9 | max-height: 150px; 10 | width: 110px; 11 | } 12 | 13 | .pltfm-logo--compressed { 14 | max-height: 140px; 15 | width: 100px; 16 | margin: 5px; 17 | } 18 | 19 | .heading__pltfm-logo { 20 | font-weight: 400; 21 | margin-bottom: 0; 22 | } 23 | 24 | -------------------------------------------------------------------------------- /src/pages/landing/images/legislator.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/pages/landing/images/collaboration.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/components/legislator/tagMap.js: -------------------------------------------------------------------------------- 1 | const tagMap = { 2 | economy: { name: 'economy', badge: 'badge-gold' }, 3 | 'justice & equality': { name: 'justice & equality', badge: 'badge-purple' }, 4 | government: { 5 | name: 'government', 6 | badge: 'badge-cyan', 7 | }, 8 | environment: { name: 'environment', badge: 'badge-green' }, 9 | } 10 | 11 | const getTagData = (tag) => { 12 | return ( 13 | (tag && tagMap[tag.toLowerCase()]) || { 14 | name: tag, 15 | badge: 'badge-default', 16 | } 17 | ) 18 | } 19 | 20 | export default getTagData 21 | -------------------------------------------------------------------------------- /src/images/help.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/styles/index.scss: -------------------------------------------------------------------------------- 1 | @import 'variables.scss'; 2 | @import './../../node_modules/bootstrap/scss/bootstrap.scss'; 3 | @import './../../node_modules/react-responsive-tabs/styles.css'; 4 | @import './../../node_modules/react-sticky-table/dist/react-sticky-table.css'; 5 | @import './../../node_modules/react-tippy/dist/tippy.css'; 6 | @import 'bootstrap-overrides.scss'; 7 | @import 'variables'; 8 | @import 'general.scss'; 9 | @import 'search.scss'; 10 | @import 'my-legislators.scss'; 11 | @import 'landing.scss'; 12 | @import 'legislator-metadata.scss'; 13 | @import 'legislation-metadata.scss'; 14 | @import 'widget-overrides.scss'; 15 | @import 'header.scss'; 16 | @import 'sponsorships.scss'; 17 | -------------------------------------------------------------------------------- /src/pages/all-legislators/SortButton/sort-arrows-selected.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/components/legislator/NewSenatorMessage.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const NewSenatorMessage = () => { 4 | return ( 5 |
6 |

7 | 8 | We were unable to find votes by this legislator for the 2017-2018 9 | session. 10 | 11 |

12 |

13 | This most likely means he or she is a first-term state senator. 14 |

15 |

16 | You can follow the profile link above to learn more about the current 17 | state senator, or check out the voting table below to view the previous 18 | senator's voting record. 19 |

20 |
21 | ) 22 | } 23 | 24 | export default NewSenatorMessage 25 | -------------------------------------------------------------------------------- /src/components/legislator/ErrorView.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Link } from 'gatsby' 4 | 5 | const ErrorView = (props) => { 6 | return ( 7 |
8 |
11 |

Sorry, there was an error.

12 |

{props.error}

13 | 14 | Try again 15 | 16 |
17 |
18 | ) 19 | } 20 | 21 | export default ErrorView 22 | 23 | ErrorView.propTypes = { 24 | error: PropTypes.string, 25 | } 26 | -------------------------------------------------------------------------------- /src/pages/landing/SearchInstructions.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'gatsby' 3 | const SearchInstructions = () => { 4 | return ( 5 |
6 |

Check On Your Legislators:

7 |
    8 |
  1. 9 | 10 | Look up who your legislators are 11 | 12 |
  2. 13 |
  3. 14 | Pull up their scorecards 15 |
  4. 16 |
  5. 17 | See who has sponsored progressive bills 18 |
  6. 19 |
20 |
21 | ) 22 | } 23 | 24 | export default SearchInstructions 25 | -------------------------------------------------------------------------------- /processData/downloadAndBuildData/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const downloadGoogleSheets = require('./downloadGoogleSheets') 4 | const buildLegislationData = require('./buildLegislationData') 5 | const updateLegislatorData = require('./updateLegislatorData') 6 | 7 | const downloadAndBuildData = async () => { 8 | // fetch sheets from Google 9 | await downloadGoogleSheets() 10 | // process into json tree 11 | const processedData = buildLegislationData() 12 | // fetch legislator details from openStates 13 | await updateLegislatorData() 14 | 15 | fs.writeFileSync( 16 | `${__dirname}/../../src/data/legislation.json`, 17 | JSON.stringify(processedData, null, 2) 18 | ) 19 | console.log(`wrote data to ${path.join(__dirname, '/../../src/data')}`) 20 | } 21 | 22 | downloadAndBuildData() 23 | -------------------------------------------------------------------------------- /src/styles/_my-legislators.scss: -------------------------------------------------------------------------------- 1 | .table-container { 2 | padding-top: 0; 3 | min-height: 35vh; 4 | } 5 | 6 | .votes-fw { 7 | width: 2rem; 8 | display: inline-block; 9 | text-align: right; 10 | margin-right: 0.1rem; 11 | } 12 | 13 | .sticky { 14 | tr td:first-of-type, 15 | tr th:first-of-type { 16 | padding-left: 0; 17 | } 18 | } 19 | 20 | .your-legislator-tabs { 21 | .RRT__tab--selected { 22 | background-color: $gray-200; 23 | } 24 | .RRT__tab { 25 | display: flex; 26 | justify-content: center; 27 | align-items: center; 28 | font-family: $headings-font-family; 29 | padding: 0; 30 | a { 31 | padding: 1rem; 32 | display: block; 33 | width: 100%; 34 | height: 100%; 35 | &:hover, 36 | &:focus { 37 | text-decoration: none; 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /functions/getLocalLegislatorsData/geolocate.js: -------------------------------------------------------------------------------- 1 | const functions = require('firebase-functions') 2 | const axios = require('axios') 3 | 4 | function geolocate(address) { 5 | return axios 6 | .get('https://maps.googleapis.com/maps/api/geocode/json', { 7 | params: { 8 | address: address, 9 | key: functions.config().keys.google_api_key, 10 | }, 11 | }) 12 | .then(function(response) { 13 | if (response.data.results.length) { 14 | return response.data.results[0].geometry.location 15 | } else { 16 | // couldn't geolocate the address 17 | throw new Error( 18 | JSON.stringify({ 19 | name: "Couldn't locate that Massachusetts address.", 20 | data: response.data, 21 | }) 22 | ) 23 | } 24 | }) 25 | } 26 | 27 | module.exports = geolocate 28 | -------------------------------------------------------------------------------- /src/styles/_header.scss: -------------------------------------------------------------------------------- 1 | 2 | header { 3 | box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08); 4 | @extend .heading-font; 5 | z-index: 5; 6 | position: relative; 7 | } 8 | 9 | .header__home-link { 10 | border-bottom: 0; 11 | color: $body-color; 12 | &:hover, &:focus { 13 | text-decoration: none; 14 | } 15 | } 16 | 17 | header nav a { 18 | display: inline-block; 19 | font-weight: bold; 20 | border-bottom: .3rem solid $white; 21 | border-top: .3rem solid $white; 22 | &:hover, &:focus { 23 | text-decoration: none; 24 | color: $primary; 25 | border-bottom-color: mix($primary, $white, 20%); 26 | } 27 | 28 | &.active { 29 | border-color: $primary; 30 | } 31 | &.btn { 32 | font-weight: bold; 33 | } 34 | } 35 | 36 | header nav ul { 37 | margin: 0; 38 | } 39 | 40 | 41 | header a { 42 | font-size: 1.1rem; 43 | } 44 | -------------------------------------------------------------------------------- /src/pages/all-legislators/SortButton/sort-arrows-faded.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Group 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "functions", 3 | "description": "Cloud Functions for Firebase", 4 | "engines": { 5 | "node": "14" 6 | }, 7 | "scripts": { 8 | "lint": "echo 'skipping firebase linting'", 9 | "serve": "firebase serve --only functions", 10 | "shell": "firebase functions:shell", 11 | "start": "npm run shell", 12 | "deploy": "firebase deploy --only functions", 13 | "logs": "firebase functions:log" 14 | }, 15 | "dependencies": { 16 | "axios": "^0.21.2", 17 | "body-parser": "^1.18.3", 18 | "cors": "^2.8.5", 19 | "express": "^4.17.3", 20 | "firebase-admin": "~7.0.0", 21 | "firebase-functions": "^2.2.1", 22 | "ssl-root-cas": "^1.3.1" 23 | }, 24 | "devDependencies": { 25 | "eslint": "^5.16.0", 26 | "eslint-config-standard-react": "^7.0.2", 27 | "eslint-plugin-babel": "^5.3.0", 28 | "eslint-plugin-promise": "^4.0.1" 29 | }, 30 | "private": true 31 | } 32 | -------------------------------------------------------------------------------- /src/pages/landing/images/fine_print.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/components/legislator/images/survey.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /test/downloadSocialImages.js: -------------------------------------------------------------------------------- 1 | var env; 2 | if (process.env.STAGING) { 3 | env = 'staging'; 4 | } else if (process.env.PRODUCTION){ 5 | env = 'production'; 6 | } else { 7 | env = 'development'; 8 | } 9 | const result = require("dotenv").config({ 10 | path: '.env.' + env, 11 | }) 12 | const request = require('request') 13 | const fs = require('fs') 14 | if (result.error) { 15 | throw result.error 16 | } 17 | 18 | const houseLegislators = require('../src/data/house_legislators.json') 19 | const senateLegislators = require('../src/data/senate_legislators.json') 20 | const domain = process.env.GATSBY_DOMAIN; 21 | 22 | houseLegislators.concat(senateLegislators).forEach(legislator => { 23 | 24 | const ogImageFilename = 25 | (legislator.name + '-' + legislator.district) 26 | .toLowerCase() 27 | .replace(/ /g, '-') 28 | .replace(/[.,']/g, '') 29 | .normalize("NFD").replace(/[\u0300-\u036f]/g, ""); // stackoverflow.com/questions/990904 30 | const url = `${domain}og-images/legislator/${ogImageFilename}.png`; 31 | request(url).pipe(fs.createWriteStream('test/tmp/' + ogImageFilename)); 32 | }); 33 | 34 | -------------------------------------------------------------------------------- /functions/getLocalLegislatorsData/findLocalLegislators.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios') 2 | const path = require('path'); 3 | const https = require('https'); 4 | const rootCas = require('ssl-root-cas').inject(); 5 | 6 | rootCas.addFile(path.resolve(__dirname, 'certs/intermediate.pem')); 7 | const httpsAgent = new https.Agent({ca: rootCas}); 8 | 9 | function findLocalLegislators(coordinates) { 10 | return axios.get('https://malegislature.gov/Legislators/GetDistrictByLatLong', { 11 | params: { 12 | latitude: coordinates.lat, 13 | longitude: coordinates.lng, 14 | isDistrictSearch: false, 15 | }, 16 | httpsAgent, 17 | }).then(response => { 18 | memberCodes = {} 19 | response.data.districts.forEach(({ Branch, UserMemberCode }) => { 20 | if (Branch === 'Senate') { 21 | memberCodes.senator = UserMemberCode 22 | } else if (Branch === 'House') { 23 | memberCodes.representative = UserMemberCode 24 | } else { 25 | throw new Error(`Unexpected chamber: ${Branch}`) 26 | } 27 | }) 28 | return memberCodes 29 | }).catch(function(error) { 30 | console.error('malegislature error:', error) 31 | throw error 32 | }) 33 | } 34 | 35 | module.exports = findLocalLegislators 36 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 2022, 4 | "sourceType": "module", 5 | "ecmaFeatures": { 6 | "jsx": true 7 | } 8 | }, 9 | "extends": [ 10 | "eslint:recommended", 11 | "plugin:import/errors", 12 | "plugin:react/recommended", 13 | "plugin:jsx-a11y/recommended", 14 | "prettier" 15 | ], 16 | "plugins": [ 17 | "react", 18 | "import", 19 | "jsx-a11y" 20 | ], 21 | "env": { 22 | "browser": true, 23 | "es6": true, 24 | "node": true 25 | }, 26 | "globals": { 27 | "__DEV__": false, 28 | "__TEST__": false, 29 | "__PROD__": false, 30 | "__COVERAGE__": false 31 | }, 32 | "rules": { 33 | "no-unused-vars": "warn", 34 | "react/display-name": 0, 35 | "import/no-unused-modules": 0, 36 | "react/prop-types": 0, 37 | "react/react-in-jsx-scope": 0, 38 | "key-spacing": 0, 39 | "jsx-quotes": [ 40 | 2, 41 | "prefer-single" 42 | ], 43 | "max-len": "off", 44 | "object-curly-spacing": [ 45 | 2, 46 | "always" 47 | ], 48 | "no-return-assign": "off" 49 | }, 50 | "settings": { 51 | "react": { 52 | "version": "detect" 53 | }, 54 | "import/resolver": { 55 | "node": { 56 | "extensions": [ 57 | ".js", 58 | ".jsx" 59 | ] 60 | } 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # dotenv environment variables file 55 | .env 56 | .env.development 57 | .env.production 58 | .env.staging 59 | 60 | # gatsby files 61 | .cache/ 62 | public 63 | 64 | # Mac files 65 | .DS_Store 66 | 67 | # Yarn 68 | yarn-error.log 69 | .pnp/ 70 | .pnp.js 71 | # Yarn Integrity file 72 | .yarn-integrity 73 | 74 | .firebase 75 | .runtimeconfig.json 76 | 77 | test/tmp 78 | -------------------------------------------------------------------------------- /src/components/InfoPopover.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Tooltip } from 'react-tippy' 4 | 5 | const InfoPopover = ({ title, position, text }) => ( 6 |
12 | 16 | {title ?
{title}
: ''} 17 |

18 |

19 | } 20 | trigger='click' 21 | arrow='true' 22 | duration='200' 23 | position={position ? position : 'left'}> 24 | 38 | 39 | 40 | ) 41 | 42 | InfoPopover.propTypes = { 43 | title: PropTypes.string, 44 | text: PropTypes.string, 45 | position: PropTypes.string, 46 | } 47 | 48 | export default InfoPopover 49 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | concurrency: 4 | group: production 5 | cancel-in-progress: true 6 | 7 | on: 8 | schedule: 9 | - cron: '0 0 1-31/2 * * ' 10 | push: 11 | branches: 12 | - main 13 | workflow_dispatch: 14 | 15 | jobs: 16 | deploy: 17 | runs-on: macos-15-intel 18 | environment: 19 | name: production 20 | url: https://scorecard.progressivemass.com 21 | steps: 22 | - uses: actions/checkout@v2 23 | - uses: actions/setup-node@v1 24 | with: 25 | node-version: '16.19.0' 26 | - name: Get yarn cache 27 | id: yarn-cache 28 | run: echo "::set-output name=dir::$(yarn cache dir)" 29 | - name: Cache node modules 30 | uses: actions/cache@v4 31 | with: 32 | path: ${{ steps.yarn-cache.outputs.dir }} 33 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 34 | restore-keys: | 35 | ${{ runner.os }}-yarn- 36 | - run: yarn install 37 | 38 | - name: Deploy site 39 | env: 40 | GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} 41 | OPENSTATES_API_KEY: ${{ secrets.OPENSTATES_API_KEY }} 42 | GATSBY_SERVERLESS_ENDPOINT: ${{ vars.GATSBY_SERVERLESS_ENDPOINT }} 43 | FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }} 44 | GATSBY_DOMAIN: ${{ vars.GATSBY_DOMAIN }} 45 | run: yarn run deploy:prod 46 | -------------------------------------------------------------------------------- /functions/index.js: -------------------------------------------------------------------------------- 1 | const functions = require('firebase-functions') 2 | const express = require('express') 3 | const getLocalLegislatorsData = require('./getLocalLegislatorsData') 4 | const cors = require('cors') 5 | const bodyParser = require('body-parser') 6 | 7 | const app = express() 8 | app.use(bodyParser.urlencoded({ extended: false })) 9 | 10 | // allow requests only from my website, the proxied url, and localhost 11 | const corsOptions = { 12 | origin: [ 13 | /^https:\/\/progressive-mass.firebaseapp.com\/*/, 14 | /^https:\/\/scorecard.progressivemass.com\/*/, 15 | /^https:\/\/progressive-mass-test.firebaseapp.com\/*/, 16 | ], 17 | } 18 | 19 | // to test in firebase console: api.post('/local-legislators').form({ address: '24 Beacon St, Boston, MA 02133'}) 20 | 21 | // ======================================================== 22 | // send an address, get your state rep + senator 23 | // ======================================================== 24 | app.options('/local-legislators', cors(corsOptions)) 25 | app.post('/local-legislators', cors(corsOptions), function(req, res) { 26 | const address = req.body.address 27 | if (!address) 28 | res.status(500).send('Need to provide an address as a query param') 29 | 30 | getLocalLegislatorsData(address) 31 | .then(data => res.status(200).json(data)) 32 | .catch(e => res.status(500).send(e.toString())) 33 | }) 34 | 35 | exports.api = functions.https.onRequest(app) 36 | -------------------------------------------------------------------------------- /gatsbyNodeHelper/buildSponsorshipCumulativeData.js: -------------------------------------------------------------------------------- 1 | const legislationData = require('../src/data/legislation.json') 2 | 3 | const median = arr => arr.sort((a, b) => a - b)[Math.floor(arr.length / 2)] 4 | const round = num => parseFloat(num.toFixed(2)) 5 | 6 | const buildSponsorshipCumulativeData = (year, legislatorData) => { 7 | const allLegislatorData = [ 8 | ...legislatorData['senate'], 9 | ...legislatorData['house'], 10 | ] 11 | 12 | const isDemocrat = data => data.party === 'Democratic' 13 | 14 | const sponsorship = legislationData[year].sponsorship 15 | .map(data => { 16 | const legislatorDescription = allLegislatorData.find( 17 | legislator => legislator.id === data.id 18 | ) 19 | return legislatorDescription 20 | ? { ...data, party: legislatorDescription.party } 21 | : undefined 22 | }) 23 | .filter(Boolean) 24 | const democratMedian = round( 25 | median(sponsorship.filter(data => isDemocrat(data)).map(data => data.score)) 26 | ) 27 | // ok technically "non democrat" 28 | const republicanMedian = round( 29 | median( 30 | sponsorship.filter(data => !isDemocrat(data)).map(data => data.score) 31 | ) 32 | ) 33 | 34 | return { 35 | term: `${year}-${parseInt(year) + 1}`, 36 | median: { 37 | democrat: democratMedian, 38 | republican: republicanMedian, 39 | }, 40 | } 41 | } 42 | 43 | module.exports = buildSponsorshipCumulativeData 44 | -------------------------------------------------------------------------------- /src/pages/all-legislators/SortButton/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import sortArrowFaded from './sort-arrows-faded.svg' 4 | import sortArrowSelected from './sort-arrows-selected.svg' 5 | 6 | const SortButton = ({ currentSort = [], sort, onClick, title }) => { 7 | const image = 8 | currentSort[0] === sort ? ( 9 | Legislators are sorted by this column - click to reverse sorting 14 | ) : ( 15 | Click to sort legislators by this column 20 | ) 21 | 22 | const rotated = currentSort[0] === sort && currentSort[1] === 'desc' 23 | 24 | return ( 25 | 34 | ) 35 | } 36 | 37 | SortButton.propTypes = { 38 | sort: PropTypes.string.isRequired, 39 | currentSort: PropTypes.array.isRequired, 40 | onClick: PropTypes.func.isRequired, 41 | title: PropTypes.string.isRequired, 42 | } 43 | 44 | export default SortButton 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![.github/workflows/deploy.yml](https://github.com/ProgressiveMass/legislator-scorecard/workflows/.github/workflows/deploy.yml/badge.svg?branch=master) 2 | 3 | # Progressive Massachusetts Legislator Scorecard 4 | 5 | Taking somewhat dry legislator information from various spreadsheets administered by [Progressive Massachusetts](https://www.progressivemass.com/) and turning it into a ...slightly less dry interactive app. 6 | 7 | [**Check out the scorecard app here**](http://scorecard.progressivemass.com) 8 | 9 | ## Technical details 10 | 11 | The gatsbyjs statically-generated site [uses Google Sheets as a lightweight CMS](https://docs.google.com/spreadsheets/d/17SfLTsqLaoBG8WE5vKHmBY_J6Iz1IFKThm_wAqsHZdg) and also sources additional legislator data from the [Open States API](https://docs.openstates.org/en/latest/api/v2/). 12 | 13 | Hosted on Google Firebase, with a Firebase serverless function for fetching geolocation data, and automated deployments using Github actions. 14 | 15 | Site created by [@aholachek](https://github.com/aholachek) and currently administered by [@dscush](https://github.com/dscush). 16 | 17 | ![screenshot of the scorecard](./screenshot.png) 18 | 19 | ## Contributing 20 | 21 | You can build the site locally by simply running `yarn start` after cloning the repo. To build the data, you'll need to copy `.env.template` to `.env` and then add your own Google API key (it must be Google Sheets enabled) and an Open States API key. You should then be able to run the `yarn build-data` command to fetch legislator data. 22 | -------------------------------------------------------------------------------- /src/components/progressBar/ProgressBar.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | function getLetterGrade(score) { 5 | const letterMap = { 6 | 6: 'D', 7 | 7: 'C', 8 | 8: 'B', 9 | 9: 'A', 10 | } 11 | const getSymbol = (num) => { 12 | if (num < 4) { 13 | return '-' 14 | } else if (num > 7) { 15 | return '+' 16 | } else { 17 | return ' ' 18 | } 19 | } 20 | const num1 = (score + '').slice(0, 1) 21 | const num2 = (score + '').slice(1) 22 | if (score === 100) { 23 | return 'A+' 24 | } else if (score < 60) { 25 | return 'F' 26 | } else { 27 | return letterMap[num1] + getSymbol(num2) 28 | } 29 | } 30 | 31 | const ProgressBar = ({ width, large, gradeOnly }) => { 32 | return ( 33 |
34 |
35 |
36 | 41 | {getLetterGrade(width)}{gradeOnly ? '' : <>   ({width + '%'})} 42 | 43 |
44 | 45 |
49 |
50 |
51 | ) 52 | } 53 | 54 | ProgressBar.propTypes = { 55 | width: PropTypes.number.isRequired, 56 | large: PropTypes.bool, 57 | } 58 | 59 | export default ProgressBar 60 | -------------------------------------------------------------------------------- /src/styles/_widget-overrides.scss: -------------------------------------------------------------------------------- 1 | .RRT__tabs { 2 | display: flex; 3 | flex-wrap: nowrap; 4 | } 5 | 6 | .RRT__panel { 7 | margin-top: 0; 8 | padding: 0; 9 | border: 0; 10 | } 11 | 12 | .RRT__tab { 13 | border-radius: 6px 6px 0px 0px; 14 | background: transparent; 15 | border: 0; 16 | cursor: pointer; 17 | z-index: 1; 18 | white-space: nowrap; 19 | font-family: $headings-font-family; 20 | font-size: 1rem; 21 | padding: 1rem; 22 | flex-grow: 1; 23 | text-align: center; 24 | @include media-breakpoint-up(sm) { 25 | font-size: 1.5rem; 26 | padding: 1rem 2rem; 27 | } 28 | } 29 | 30 | .RRT__tab--selected { 31 | background: $light; 32 | font-weight: bold; 33 | border: 0; 34 | } 35 | 36 | .RRT__tab--selected:focus { 37 | background-color: $light; 38 | } 39 | 40 | .RRT__tab:not(.RRT__tab--selected):hover { 41 | background-color: fade-out(#000, .95); 42 | } 43 | 44 | .inverted-tabs { 45 | @extend .module-container; 46 | @extend .module-container--full-width-on-small; 47 | .RRT__tab--selected { 48 | background: $white; 49 | } 50 | } 51 | 52 | .tippy-tooltip { 53 | font-size : 1rem; 54 | max-width: 400px; 55 | text-align: left; 56 | font-family: $font-family-sans-serif; 57 | padding: 1rem !important; 58 | p { 59 | margin-bottom: 0; 60 | } 61 | &:focus { 62 | outline: none; 63 | } 64 | 65 | a { 66 | color: $info; 67 | &:hover, &:focus { 68 | color: $info; 69 | } 70 | } 71 | } 72 | 73 | .tippy-popper:focus { 74 | outline: none; 75 | } 76 | -------------------------------------------------------------------------------- /src/utilities.js: -------------------------------------------------------------------------------- 1 | const termDict = { 2 | '2015-2016': '189th', 3 | '2017-2018': '190th', 4 | '2019-2020': '191st', 5 | '2021-2022': '192nd', 6 | '2023-2024': '193rd', 7 | '2025-2026': '194th', 8 | } 9 | 10 | const getSessionNumber = (yearRange) => termDict[yearRange] 11 | 12 | const getLegislatorUrlParams = (legislator) => { 13 | const firstName = legislator.givenName ?? legislator.given_name 14 | const lastName = legislator.familyName ?? legislator.family_name 15 | return `${firstName.toLowerCase()}-${lastName.toLowerCase()}` 16 | } 17 | 18 | const isHouseRep = (legislator) => isMemberOfChamber(legislator, 'mahouse') 19 | 20 | const isSenator = (legislator) => isMemberOfChamber(legislator, 'masenate') 21 | 22 | const isMemberOfChamber = (legislator, chamber) => { 23 | try { 24 | return legislator.email.includes(chamber) 25 | } catch (e) { 26 | throw new Error("Problem checking email for legislator with ID: " + legislator.id) 27 | } 28 | } 29 | 30 | const BREAKPOINTS = { 31 | phone: 600, 32 | tablet: 950, 33 | laptop: 1300, 34 | } 35 | 36 | const QUERIES = { 37 | phoneAndSmaller: `(max-width: ${BREAKPOINTS.phone / 16}rem)`, 38 | tabletAndSmaller: `(max-width: ${BREAKPOINTS.tablet / 16}rem)`, 39 | laptopAndSmaller: `(max-width: ${BREAKPOINTS.laptop / 16}rem)`, 40 | phoneAndUp: `(min-width: ${BREAKPOINTS.phone / 16}rem)`, 41 | tabletAndUp: `(min-width: ${BREAKPOINTS.tablet / 16}rem)`, 42 | laptopAndUp: `(min-width: ${BREAKPOINTS.laptop / 16}rem)`, 43 | } 44 | 45 | module.exports = { 46 | getSessionNumber, 47 | getLegislatorUrlParams, 48 | isHouseRep, 49 | isSenator, 50 | BREAKPOINTS, 51 | QUERIES, 52 | } 53 | -------------------------------------------------------------------------------- /src/components/legislator/YourLegislatorTabs.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { Link } from 'gatsby' 3 | import qs from 'query-string' 4 | import { getLegislatorUrlParams } from '../../utilities' 5 | 6 | const YourLegislatorTabs = ({ currentLegislator }) => { 7 | const urlId = getLegislatorUrlParams(currentLegislator) 8 | 9 | const [search, setSearch] = useState({}) 10 | 11 | useEffect(() => { 12 | const search = qs.parse(window.location.search) 13 | setSearch(search) 14 | }, []) 15 | 16 | const isPersonalizedView = search.yourSenator || search.yourRep 17 | if (!isPersonalizedView) return null 18 | 19 | const selectedClass = 'RRT__tab--selected' 20 | return ( 21 |
22 |
26 | {search.yourRep ? ( 27 | 'Your Senator' 28 | ) : ( 29 | 30 | Your Senator 31 | 32 | )} 33 |
34 |
35 | {search.yourSenator ? ( 36 | 'Your Rep' 37 | ) : ( 38 | 44 | Your House Rep 45 | 46 | )} 47 |
48 |
49 | ) 50 | } 51 | 52 | export default YourLegislatorTabs 53 | -------------------------------------------------------------------------------- /gatsbyNodeHelper/buildVoteCumulativeData.js: -------------------------------------------------------------------------------- 1 | const legislationData = require('../src/data/legislation.json') 2 | 3 | const median = arr => arr.sort((a, b) => a - b)[Math.floor(arr.length / 2)] 4 | const round = num => parseFloat(num.toFixed(2)) 5 | 6 | const buildChamberVoteData = (year, legislatorData) => chamber => { 7 | const isDemocrat = data => data.party === 'Democratic' 8 | 9 | const votes = legislationData[year][`${chamber}Votes`] 10 | .map(data => { 11 | const legislatorDescription = legislatorData[chamber].find( 12 | legislator => legislator && legislator.id === data.id 13 | ) 14 | return legislatorDescription 15 | ? { ...data, party: legislatorDescription.party } 16 | : undefined 17 | }) 18 | .filter(Boolean) 19 | 20 | const allDemocratVotes = votes 21 | .filter(vote => vote.recordedVotePercentage > 90) 22 | .filter(vote => { 23 | return isDemocrat(vote) 24 | }) 25 | .map(vote => vote.score) 26 | 27 | const democratMedian = round(median(allDemocratVotes)) 28 | const republicanVotes = votes 29 | .filter(vote => vote.recordedVotePercentage > 90) 30 | .filter(vote => { 31 | return !isDemocrat(vote) 32 | }) 33 | .map(vote => vote.score) 34 | 35 | const republicanMedian = round(median(republicanVotes)) 36 | return { 37 | term: `${year}-${parseInt(year) + 1}`, 38 | median: { 39 | democrat: democratMedian, 40 | republican: republicanMedian, 41 | }, 42 | } 43 | } 44 | 45 | const buildVoteCumulativeData = (year, legislatorData) => { 46 | return { 47 | house: buildChamberVoteData(year, legislatorData)('house'), 48 | senate: buildChamberVoteData(year, legislatorData)('senate'), 49 | } 50 | } 51 | module.exports = buildVoteCumulativeData 52 | -------------------------------------------------------------------------------- /functions/getLocalLegislatorsData/certs/intermediate.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIEyDCCA7CgAwIBAgIQDPW9BitWAvR6uFAsI8zwZjANBgkqhkiG9w0BAQsFADBh 3 | MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 4 | d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH 5 | MjAeFw0yMTAzMzAwMDAwMDBaFw0zMTAzMjkyMzU5NTlaMFkxCzAJBgNVBAYTAlVT 6 | MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxMzAxBgNVBAMTKkRpZ2lDZXJ0IEdsb2Jh 7 | bCBHMiBUTFMgUlNBIFNIQTI1NiAyMDIwIENBMTCCASIwDQYJKoZIhvcNAQEBBQAD 8 | ggEPADCCAQoCggEBAMz3EGJPprtjb+2QUlbFbSd7ehJWivH0+dbn4Y+9lavyYEEV 9 | cNsSAPonCrVXOFt9slGTcZUOakGUWzUb+nv6u8W+JDD+Vu/E832X4xT1FE3LpxDy 10 | FuqrIvAxIhFhaZAmunjZlx/jfWardUSVc8is/+9dCopZQ+GssjoP80j812s3wWPc 11 | 3kbW20X+fSP9kOhRBx5Ro1/tSUZUfyyIxfQTnJcVPAPooTncaQwywa8WV0yUR0J8 12 | osicfebUTVSvQpmowQTCd5zWSOTOEeAqgJnwQ3DPP3Zr0UxJqyRewg2C/Uaoq2yT 13 | zGJSQnWS+Jr6Xl6ysGHlHx+5fwmY6D36g39HaaECAwEAAaOCAYIwggF+MBIGA1Ud 14 | EwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFHSFgMBmx9833s+9KTeqAx2+7c0XMB8G 15 | A1UdIwQYMBaAFE4iVCAYlebjbuYP+vq5Eu0GF485MA4GA1UdDwEB/wQEAwIBhjAd 16 | BgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwdgYIKwYBBQUHAQEEajBoMCQG 17 | CCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wQAYIKwYBBQUHMAKG 18 | NGh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbFJvb3RH 19 | Mi5jcnQwQgYDVR0fBDswOTA3oDWgM4YxaHR0cDovL2NybDMuZGlnaWNlcnQuY29t 20 | L0RpZ2lDZXJ0R2xvYmFsUm9vdEcyLmNybDA9BgNVHSAENjA0MAsGCWCGSAGG/WwC 21 | ATAHBgVngQwBATAIBgZngQwBAgEwCAYGZ4EMAQICMAgGBmeBDAECAzANBgkqhkiG 22 | 9w0BAQsFAAOCAQEAkPFwyyiXaZd8dP3A+iZ7U6utzWX9upwGnIrXWkOH7U1MVl+t 23 | wcW1BSAuWdH/SvWgKtiwla3JLko716f2b4gp/DA/JIS7w7d7kwcsr4drdjPtAFVS 24 | slme5LnQ89/nD/7d+MS5EHKBCQRfz5eeLjJ1js+aWNJXMX43AYGyZm0pGrFmCW3R 25 | bpD0ufovARTFXFZkAdl9h6g4U5+LXUZtXMYnhIHUfoyMo5tS58aI7Dd8KvvwVVo4 26 | chDYABPPTHPbqjc1qCmBaZx2vN4Ye5DUys/vZwP9BFohFrH/6j/f3IL16/RZkiMN 27 | JCqVJUzKoZHm1Lesh3Sz8W2jmdv51b2EQJ8HmA== 28 | -----END CERTIFICATE----- 29 | -------------------------------------------------------------------------------- /src/components/legislator/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import SEO from '../seo' 4 | import LegislatorMetadata from './LegislatorMetadata' 5 | import SessionTabs from './SessionTabs' 6 | import Layout from '../layout' 7 | import YourLegislatorTabs from './YourLegislatorTabs' 8 | 9 | const LegislatorPage = ({ 10 | pageContext: { 11 | chamber, 12 | // id, 13 | pageData, 14 | ogImage, 15 | }, 16 | }) => { 17 | const legislatorTitle = chamber === 'senate' ? 'Senator' : 'Rep' 18 | const familyName = pageData.legislator.familyName 19 | const seoTitle = `${legislatorTitle} ${pageData.legislator.name}'s Voting Record` 20 | const seoDescription = `Learn about ${legislatorTitle} ${pageData.legislator.name}'s values by viewing a record of their activity in the Massachusetts statehouse.` 21 | return ( 22 | 23 | 24 | 25 |
26 | 33 | 40 |
41 |
42 | ) 43 | } 44 | 45 | LegislatorPage.propTypes = { 46 | legislator: PropTypes.object.isRequired, 47 | data: PropTypes.object.isRequired, 48 | chamber: PropTypes.string.isRequired, 49 | rating: PropTypes.object.isRequired, 50 | } 51 | 52 | export default LegislatorPage 53 | -------------------------------------------------------------------------------- /src/styles/_landing.scss: -------------------------------------------------------------------------------- 1 | .photo-background { 2 | background-image: linear-gradient( 3 | to bottom right, 4 | fade-out($gray-900, 0.09), 5 | fade-out(darken($gray-900, 0.14), 0.07) 6 | ), 7 | url("./../images/dome.jpg"); 8 | background-size: cover; 9 | } 10 | 11 | .landing__header--1 { 12 | padding: 5rem 0; 13 | 14 | hr { 15 | border-top-color: $primary; 16 | width: 50%; 17 | @include media-breakpoint-up(md) { 18 | width: 100%; 19 | } 20 | } 21 | 22 | h1 { 23 | font-weight: 300; 24 | text-align: center; 25 | @include media-breakpoint-up(md) { 26 | text-align: left; 27 | } 28 | } 29 | .landing__cta, landing__h1 { 30 | font-weight: 300; 31 | font-size: 1.8rem; 32 | text-align: center; 33 | @include media-breakpoint-up(md) { 34 | font-size: 2rem; 35 | text-align: left; 36 | } 37 | @include media-breakpoint-up(lg) { 38 | font-size: 2.2rem; 39 | } 40 | @include media-breakpoint-up(xl) { 41 | font-size: 2.4rem; 42 | } 43 | } 44 | position: relative; 45 | color: white; 46 | @extend .photo-background; 47 | 48 | .btn-outline-secondary:hover { 49 | background: fade-out(white, 0.9); 50 | } 51 | } 52 | 53 | .landing__section--1 { 54 | padding: 5rem 0; 55 | } 56 | 57 | .landing__section--2 { 58 | @extend .photo-background; 59 | padding: 5rem 0; 60 | 61 | .landing-section-card { 62 | border-radius: 6px; 63 | max-width: 400px; 64 | } 65 | 66 | .h1 { 67 | text-align: center; 68 | font-size: 2rem; 69 | color: white; 70 | @include media-breakpoint-up(md) { 71 | font-size: 2.5rem; 72 | } 73 | } 74 | 75 | img { 76 | width: 80%; 77 | max-width: 110px; 78 | margin: auto; 79 | display: block; 80 | } 81 | 82 | .h4 { 83 | margin: 1rem 0; 84 | } 85 | 86 | .white-background { 87 | height: 100%; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /gatsby-config.js: -------------------------------------------------------------------------------- 1 | if (process.env.STAGING) { 2 | require("dotenv").config({ 3 | path: `.env.staging`, 4 | }) 5 | console.log("Building for staging environment") 6 | } else { 7 | require("dotenv").config({ 8 | path: `.env.${process.env.NODE_ENV}`, 9 | }) 10 | } 11 | 12 | module.exports = { 13 | siteMetadata: { 14 | title: `Progressive Massachusetts Legislator Scorecard`, 15 | description: `Learn about MA state legislation and review your legislators' records`, 16 | author: `Alex Holachek`, 17 | siteUrl: 'https://scorecard.progressivemass.com/', 18 | }, 19 | plugins: [ 20 | `gatsby-plugin-react-helmet`, 21 | `gatsby-plugin-sass`, 22 | `gatsby-transformer-json`, 23 | 'gatsby-plugin-robots-txt', 24 | `gatsby-plugin-open-graph-images`, 25 | `gatsby-plugin-styled-components`, 26 | { 27 | resolve: 'gatsby-plugin-sitemap', 28 | options: { 29 | exclude: [ 30 | '/all-legislators/LegislatorList/', 31 | '/all-legislators/SortButton/', 32 | '/landing/*', 33 | ], 34 | }, 35 | }, 36 | { 37 | resolve: `gatsby-source-filesystem`, 38 | options: { 39 | path: `./src/data/`, 40 | }, 41 | }, 42 | `gatsby-transformer-sharp`, 43 | `gatsby-plugin-sharp`, 44 | { 45 | resolve: `gatsby-plugin-manifest`, 46 | options: { 47 | name: `Progressive Massachusetts Legislator Scorecard`, 48 | short_name: `Prog Mass Scorecard`, 49 | start_url: `/`, 50 | background_color: `#037BFF`, 51 | theme_color: `#037BFF`, 52 | display: `minimal-ui`, 53 | icon: `src/images/progressive-mass-logo.png`, // This path is relative to the root of the site. 54 | }, 55 | }, 56 | { 57 | resolve: `gatsby-plugin-google-analytics`, 58 | options: { 59 | trackingId: 'UA-93532804-1', 60 | }, 61 | }, 62 | // this (optional) plugin enables Progressive Web App + Offline functionality 63 | // To learn more, visit: https://gatsby.dev/offline 64 | // "gatsby-plugin-offline", 65 | ], 66 | } 67 | -------------------------------------------------------------------------------- /processData/downloadAndBuildData/downloadGoogleSheets.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-extra') 2 | const axios = require('axios') 3 | 4 | // get some nice debugging output 5 | // curlirize(axios) 6 | 7 | require('dotenv').config() 8 | 9 | const googleSheetIds = { 10 | 2017: '15AgxGT87Qc02IqPV46Uc0u9Z_ChfAjZQaj3qS2VNF8g', 11 | 2019: '17SfLTsqLaoBG8WE5vKHmBY_J6Iz1IFKThm_wAqsHZdg', 12 | 2021: '1_WD66ZAMR4gQRq9f3s0ITayesaLoQcHIMZVTHkTA6Ug', 13 | 2023: '1Uq5Fe8F2FlRW0ns2UMbjJN9CKg2nxweE2EQfPhE6gdw', 14 | 2025: '1ZuwDsDdT2Q7ZWwi_jhXb-xoFaU3OX10RRdvBZ7SPQ1A', 15 | } 16 | 17 | const requestSheet = async (id, sheet) => { 18 | const response = await axios.get( 19 | `https://sheets.googleapis.com/v4/spreadsheets/${id}/values/${sheet}?key=${process.env.GOOGLE_API_KEY}` 20 | ) 21 | return response.data.values 22 | } 23 | 24 | const loadGoogleSheets = async (year) => { 25 | const id = googleSheetIds[year] 26 | const sheetTypes = [ 27 | 'Sponsored_Bills', 28 | 'House_Bills', 29 | 'Senate_Bills', 30 | 'Sponsorship', 31 | 'House_Votes', 32 | 'Senate_Votes', 33 | ] 34 | const sheetRequests = sheetTypes.map((sheet) => requestSheet(id, sheet)) 35 | 36 | await Promise.all(sheetRequests).then( 37 | ([sponsoredBills, houseBills, senateBills, sponsorship, houseVotes, senateVotes]) => { 38 | // there must have been a connectivity error since this always has data 39 | if (sponsoredBills.length === 0) return 40 | fs.writeFileSync( 41 | `${__dirname}/tmp/${year}.json`, 42 | JSON.stringify({ 43 | sponsoredBills, 44 | houseBills, 45 | senateBills, 46 | sponsorship, 47 | houseVotes, 48 | senateVotes, 49 | }) 50 | ) 51 | console.log(`wrote ${year} csv data to disk`) 52 | } 53 | ) 54 | } 55 | 56 | module.exports = async () => { 57 | let yearsToRefresh = [ 58 | // 2017, 59 | 2019, 2021, 2023, 2025, 60 | ] 61 | yearsToRefresh.forEach((year) => { 62 | fs.removeSync(`${__dirname}/tmp/${year}.json`) 63 | }) 64 | await Promise.all(yearsToRefresh.map((year) => loadGoogleSheets(year))) 65 | } 66 | -------------------------------------------------------------------------------- /src/components/legislator/SessionTabs.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import styled from 'styled-components' 4 | import Tabs from 'react-responsive-tabs' 5 | import TermLayout from './TermLayout' 6 | import ProgressBarWContext from '../progressBar' 7 | import { getSessionNumber, QUERIES } from '../../utilities' 8 | 9 | const Term = styled.div` 10 | @media ${QUERIES.tabletAndSmaller} { 11 | font-size: 1rem; 12 | text-wrap: wrap; 13 | } 14 | ` 15 | 16 | const Session = styled.div` 17 | font-size: 1rem; 18 | 19 | @media ${QUERIES.tabletAndSmaller} { 20 | font-size: 0.8rem; 21 | text-wrap: wrap; 22 | } 23 | ` 24 | 25 | export const SessionTabs = ({ terms, chamber, familyName }) => { 26 | const tabItems = terms.map((d) => { 27 | const sessionNumber = getSessionNumber(d.term) 28 | return { 29 | title: ( 30 |
31 | {d.term} 32 | {sessionNumber} Session 33 |
39 | 40 |
41 |
42 | ), 43 | key: d.term, 44 | getContent: () => { 45 | return ( 46 | 52 | ) 53 | }, 54 | } 55 | }) 56 | 57 | const selectedTabKey = terms[terms.length - 1].term 58 | 59 | return ( 60 |
61 |

Legislative Terms

62 | 69 |
70 | ) 71 | } 72 | 73 | export default SessionTabs 74 | 75 | SessionTabs.propTypes = { 76 | terms: PropTypes.array.isRequired, 77 | chamber: PropTypes.string.isRequired, 78 | familyName: PropTypes.string.isRequired, 79 | } 80 | -------------------------------------------------------------------------------- /src/components/legislator/ogImage.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Rating from './Rating' 4 | import defaultPhoto from '../../images/default-photo.jpg' 5 | 6 | const LegislatorOgImage = ({ pageContext: { chamber, pageData } }) => { 7 | const legislatorTitle = chamber === 'senate' ? 'Sen.' : 'Rep.' 8 | const familyName = pageData.legislator.familyName 9 | var partySuffix 10 | if (pageData.legislator.party === 'Democratic') { 11 | partySuffix = '(D)' 12 | } else if (pageData.legislator.party === 'Republican') { 13 | partySuffix = '(R)' 14 | } else { 15 | partySuffix = '(I)' 16 | } 17 | const fullName = [ 18 | legislatorTitle, 19 | pageData.legislator.name, 20 | partySuffix, 21 | ].join(' ') 22 | var fontSize = Math.min(65 - fullName.length, 40) 23 | return ( 24 |
25 |
26 |

27 | {fullName} 28 |

29 |
30 |
31 | {pageData.legislator.image ? ( 32 | {'Photo { 37 | if (e.target.src !== window.location.origin + defaultPhoto) { 38 | e.target.src = defaultPhoto 39 | } 40 | }} 41 | /> 42 | ) : null} 43 |
44 |
45 | 51 |
52 |
53 |
54 |
55 | ) 56 | } 57 | 58 | LegislatorOgImage.propTypes = { 59 | legislator: PropTypes.object.isRequired, 60 | data: PropTypes.object.isRequired, 61 | chamber: PropTypes.string.isRequired, 62 | rating: PropTypes.object.isRequired, 63 | } 64 | 65 | export default LegislatorOgImage 66 | -------------------------------------------------------------------------------- /src/components/layout/Nav.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'gatsby' 3 | import LogoImg from '../../images/scorecard-logo.png' 4 | import styled from 'styled-components' 5 | import { QUERIES } from '../../utilities' 6 | 7 | const MobileFriendlyList = styled.ul` 8 | @media ${QUERIES.phoneAndSmaller} { 9 | display: grid !important; 10 | grid-template-columns: 1fr 1fr !important; 11 | } 12 | ` 13 | const Nav = () => { 14 | return ( 15 |
16 | 60 |
61 | ) 62 | } 63 | 64 | export default Nav 65 | -------------------------------------------------------------------------------- /src/styles/_legislator-metadata.scss: -------------------------------------------------------------------------------- 1 | .metadata { 2 | font-size: 1.125rem; 3 | @extend .white-background; 4 | margin-top: 2rem; 5 | margin-bottom: 3rem; 6 | border-radius: 6px; 7 | @include media-breakpoint-down(xs) { 8 | margin-bottom: 2rem; 9 | } 10 | position: relative; 11 | } 12 | 13 | .metadata__heading { 14 | font-size: 1.8rem; 15 | line-height: 0.96; 16 | margin-bottom: 1.5rem; 17 | position: relative; 18 | display: inline-block; 19 | @include media-breakpoint-up(sm) { 20 | font-size: 2.5rem; 21 | margin-bottom: 1.5rem !important; 22 | } 23 | @include media-breakpoint-up(md) { 24 | font-size: 2.4rem; 25 | } 26 | @include media-breakpoint-up(lg) { 27 | font-size: 2.6rem; 28 | } 29 | } 30 | 31 | .legislator-portrait { 32 | max-height: 180px; 33 | min-width: 125px; 34 | max-width: 150px; 35 | object-fit: cover; 36 | object-position: top center; 37 | margin-right: 2rem; 38 | } 39 | 40 | .legislator-portrait-social { 41 | max-height: 225px; 42 | min-width: 170px; 43 | max-width: 170px; 44 | margin-right: 0; 45 | object-fit: cover; 46 | object-position: top center; 47 | } 48 | 49 | .progress-component { 50 | max-width: 500px; 51 | } 52 | 53 | .progress-component .progress { 54 | font-family: $font-family-sans-serif; 55 | } 56 | 57 | .progress { 58 | border-radius: 50px; 59 | } 60 | 61 | .progress-bar { 62 | padding-left: 0.8rem; 63 | display: flex; 64 | align-items: center; 65 | } 66 | 67 | .progress--large { 68 | font-size: 1rem; 69 | height: 1.4rem; 70 | .progress__text-container span { 71 | font-size: 0.95rem !important; 72 | } 73 | } 74 | 75 | .progress-alternative.small { 76 | line-height: 0.5rem; 77 | } 78 | 79 | .legislator-list__profile-img { 80 | width: 4rem; 81 | height: 4rem !important; 82 | object-fit: cover; 83 | object-position: 50% 0; 84 | height: auto; 85 | margin-right: 1rem; 86 | border-radius: 3px; 87 | } 88 | 89 | .legislator-row { 90 | td { 91 | vertical-align: middle; 92 | } 93 | &:hover { 94 | background-color: mix($gray-100, $gray-200) !important; 95 | } 96 | } 97 | 98 | .legislator-row__name { 99 | &:hover, 100 | &:focus { 101 | text-decoration: none; 102 | color: $primary; 103 | } 104 | } 105 | 106 | .cosponsorship-summary { 107 | line-height: 1.2; 108 | } 109 | 110 | .average-data { 111 | line-height: 1.2; 112 | margin-bottom: 0.25rem; 113 | margin-top: 0.25rem; 114 | } 115 | -------------------------------------------------------------------------------- /src/components/progressBar/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useEffect } from 'react' 3 | import PropTypes from 'prop-types' 4 | import ProgressBar from './ProgressBar' 5 | import { BREAKPOINTS } from '../../utilities' 6 | 7 | const ContextualProgressBar = ({ data: d, large, sessionNumber, utilizeGradeOnlyFlag }) => { 8 | const [gradeOnly, setGradeOnly] = React.useState(false) 9 | 10 | useEffect(() => { 11 | if (window.innerWidth > BREAKPOINTS.tablet) { 12 | setGradeOnly(false); 13 | } else if (window.innerWidth <= BREAKPOINTS.tablet) { 14 | setGradeOnly(true); 15 | } 16 | }, []); 17 | 18 | useEffect(() => { 19 | const handleResize = () => { 20 | if (window.innerWidth > BREAKPOINTS.tablet) { 21 | setGradeOnly(false); 22 | } else if (window.innerWidth <= BREAKPOINTS.tablet) { 23 | setGradeOnly(true); 24 | } 25 | } 26 | window.addEventListener('resize', handleResize); 27 | 28 | return () => { 29 | window.removeEventListener('resize', handleResize); 30 | } 31 | }, []) 32 | 33 | let noScoreClasses = 34 | 'badge badge-secondary d-block text-center px-1 py-1 rounded progress-alternative' 35 | noScoreClasses = large ? noScoreClasses : noScoreClasses + ' progress small' 36 | if (!d.score) { 37 | const sessionClause = sessionNumber 38 | ? ' from ' + sessionNumber + ' sess.' 39 | : '' 40 | return ( 41 |
42 | N/A{gradeOnly ? '' : <>:  {`no voting data${sessionClause}`}} 43 |
44 | ) 45 | } else if (d.score === 'n/a') { 46 | const sessionClause = sessionNumber 47 | ? ' for ' + sessionNumber + ' sess.' 48 | : '' 49 | return ( 50 |
51 | N/A{gradeOnly ? '' : <>:  no rating available{sessionClause}} 52 |
53 | ) 54 | } else if (d.recordedVotePercentage < 90) { 55 | return ( 56 |
57 | N/A{gradeOnly ? '' : <>: missed at least 10% of scored votes} 58 |
59 | ) 60 | } else { 61 | return ( 62 |
63 | 69 |
70 | ) 71 | } 72 | } 73 | 74 | ContextualProgressBar.propTypes = { 75 | data: PropTypes.object.isRequired, 76 | large: PropTypes.bool, 77 | } 78 | 79 | export default ContextualProgressBar 80 | -------------------------------------------------------------------------------- /src/components/legislator/TermLayout.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import SponsorshipTable from './SponsorshipTable' 4 | import VoteTable from './VoteTable' 5 | 6 | const sponsorship = 'sponsorship' 7 | const votes = 'votes' 8 | 9 | const TermLayout = (props) => { 10 | // if no votes, set active to sponsorship 11 | const [active, setActive] = React.useState( 12 | !props.data.votes || !props.data.votes.length ? sponsorship : votes 13 | ) 14 | const hasSponsorship = props.data.sponsorship && props.data.sponsorship.length 15 | const hasVotes = props.data.votes && props.data.votes.length 16 | 17 | const onTabClick = (activeTab) => (e) => { 18 | if (activeTab === 'sponsorship' && hasSponsorship) { 19 | setActive(activeTab) 20 | } else if (activeTab === 'votes' && hasVotes) { 21 | setActive(activeTab) 22 | } 23 | e.preventDefault() 24 | } 25 | 26 | const sponsorshipTab = ( 27 |
  • 28 |
    38 | Cosponsorship 39 |
    40 |
  • 41 | ) 42 | 43 | const voteTab = ( 44 |
  • 45 |
    55 | Voting Record 56 |
    57 |
  • 58 | ) 59 | 60 | let BodyComponent 61 | 62 | if (active === sponsorship) { 63 | BodyComponent = ( 64 | 65 | ) 66 | } else if (active === votes) { 67 | BodyComponent = 68 | } 69 | return ( 70 |
    71 |
      72 | {sponsorshipTab} 73 | {voteTab} 74 |
    75 |
    {BodyComponent}
    76 |
    77 | ) 78 | } 79 | 80 | export default TermLayout 81 | 82 | TermLayout.propTypes = { 83 | data: PropTypes.object.isRequired, 84 | } 85 | -------------------------------------------------------------------------------- /src/components/layout/Footer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import progressiveMassLogo from '../../images/progressive-mass-logo.png' 3 | 4 | const Footer = () => { 5 | return ( 6 | 75 | ) 76 | } 77 | 78 | export default Footer 79 | -------------------------------------------------------------------------------- /src/components/sponsorships/sponsorships.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import Layout from '../../components/layout' 4 | import Tabs from 'react-responsive-tabs' 5 | import LegislatorList from '../../pages/all-legislators/LegislatorList' 6 | import { QUERIES } from '../../utilities' 7 | 8 | const TabsContaner = styled.div` 9 | background-color: #eaecef; 10 | padding: 1rem 0; 11 | margin: 0; 12 | display: flex; 13 | flex-direction: column; 14 | @media ${QUERIES.tabletAndSmaller} { 15 | padding: 0 0.5rem; 16 | } 17 | ` 18 | const Wrapper = styled.div` 19 | width: 100%; 20 | height: 100%; 21 | background-color: #eaecef; 22 | ` 23 | 24 | const Container = styled.div` 25 | padding: 1rem 2rem; 26 | 27 | display: flex; 28 | 29 | justify-content: center; 30 | flex-direction: column; 31 | @media ${QUERIES.tabletAndSmaller} { 32 | padding: 0 2rem; 33 | } 34 | ` 35 | const BillInformation = styled.div` 36 | margin: auto; 37 | margin-top: 2rem; 38 | background-color: white; 39 | padding: 2rem; 40 | border-radius: 6px; 41 | width: 100%; 42 | ` 43 | const BillTitle = styled.h2` 44 | @media ${QUERIES.tabletAndSmaller} { 45 | font-size: 1.5rem; 46 | } 47 | ` 48 | const SponsorshipTitle = styled.h3` 49 | margin: auto; 50 | padding: 0.5rem 0; 51 | ` 52 | 53 | const LinkContainer = styled.p` 54 | span:not(:last-child)::after { 55 | content: '/'; 56 | margin: 0 5px; 57 | } 58 | ` 59 | 60 | export default function SponsoredBill({ 61 | pageContext: { billData: bill, houseSponsors, senateSponsors, votesSessionOrdinal, sponsorshipsSessionNumber }, 62 | }) { 63 | const tabItems = [ 64 | { 65 | title: `House Reps (${houseSponsors.length})`, 66 | component: , 67 | }, 68 | { 69 | title: `Senators (${senateSponsors.length})`, 70 | component: , 71 | }, 72 | ].map((t) => { 73 | return { 74 | title: t.title, 75 | getContent: () => t.component, 76 | } 77 | }) 78 | return ( 79 | 80 | 81 | 82 | 83 | {bill.shorthand_title} 84 | 85 | {bill.otherBillNames.split('/').map((bill) => ( 86 | 87 | 88 | {bill.trim()} 89 | 90 | 91 | ))} 92 | 93 |

    Full title: {bill.name}

    94 |

    Filed by {bill.sponsors}

    95 |

    {bill.description}

    96 |
    97 | 98 | Sponsors 99 | 100 | 101 |
    102 |
    103 |
    104 | ) 105 | } 106 | -------------------------------------------------------------------------------- /processData/miscDataParsingScripts/addOpenStateIdsToVotes.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const axios = require('axios') 3 | const parse = require('csv-parse/lib/sync') 4 | const stringify = require('csv-stringify/lib/sync') 5 | const stringSimilarity = require('string-similarity') 6 | 7 | require('dotenv').config() 8 | 9 | const fileName = '2017_Progressive_Mass_Data - Senate_Votes (1).csv' 10 | const isHouse = false 11 | const houseId = 'ocd-organization/ca38ad9c-c3d5-4c4f-bc2f-d885218ed802' 12 | const senateId = 'ocd-organization/1a75ab3a-669b-43fe-ac8d-31a2d6923d9a' 13 | 14 | function normalizeName(name) { 15 | return name 16 | .toLowerCase() 17 | .trim() 18 | .replace(/[.-]/g, '') 19 | } 20 | 21 | function getNormalizedLastName(name) { 22 | return normalizeName(name.split(',')[0]) 23 | } 24 | 25 | const makeQuery = names => ` 26 | query getLegislatorIds($organization: String ) { 27 | ${names.map((name, index) => { 28 | return ` 29 | _${index} : people(name: "${getNormalizedLastName( 30 | name 31 | )}", everMemberOf: $organization, first: 10) { 32 | edges { 33 | node { 34 | id 35 | name 36 | } 37 | } 38 | } 39 | ` 40 | })} 41 | } 42 | ` 43 | 44 | const makeRequest = ({ organization, names }) => { 45 | const query = makeQuery(names) 46 | // console.log(query) 47 | return axios.post( 48 | 'https://openstates.org/graphql', 49 | { 50 | query: query, 51 | variables: { 52 | organization, 53 | }, 54 | }, 55 | { 56 | headers: { 57 | 'X-API-KEY': process.env.OPENSTATES_API_KEY, 58 | }, 59 | } 60 | ) 61 | } 62 | 63 | const processData = async fileName => { 64 | const fileContents = fs.readFileSync(`${__dirname}/../${fileName}`, 'utf8') 65 | 66 | const csvContents = parse(fileContents) 67 | const rowsThatNeedIds = [] 68 | for (let i = 0; i < csvContents.length; i++) { 69 | if (csvContents[i][0]) rowsThatNeedIds.push(csvContents[i]) 70 | } 71 | 72 | const data = await makeRequest({ 73 | organization: isHouse ? houseId : senateId, 74 | names: rowsThatNeedIds.map(row => row[0]), 75 | }) 76 | 77 | rowsThatNeedIds.forEach((row, index) => { 78 | const result = data.data.data[`_${index}`].edges 79 | if (result.length === 1) { 80 | row.splice(0, 0, result[0].node.id) 81 | } else if (result.length > 1) { 82 | const name = row[0] 83 | const bestMatch = stringSimilarity.findBestMatch( 84 | name, 85 | result.map(({ node: { name } }) => name) 86 | ).bestMatch 87 | if (bestMatch.rating > 0.6) { 88 | console.log( 89 | `Via string similarity metrics, the best match for ${name} was: ${ 90 | bestMatch.target 91 | }` 92 | ) 93 | const bestMatchResult = result.find( 94 | ({ node: { name } }) => name === bestMatch.target 95 | ) 96 | row.splice(0, 0, bestMatchResult.node.id) 97 | } else { 98 | console.log(row[0], ' had no matches') 99 | row.splice(0, 0, 'NOT FOUND') 100 | } 101 | } else if (!result.length) { 102 | console.log(row[0], ' had no matches') 103 | row.splice(0, 0, 'NOT FOUND') 104 | } 105 | try { 106 | } catch (e) { 107 | console.log(row[0]) 108 | } 109 | }) 110 | 111 | fs.writeFileSync(`${fileName}-with-id.csv`, stringify(csvContents)) 112 | } 113 | 114 | processData(fileName) 115 | -------------------------------------------------------------------------------- /src/components/seo.js: -------------------------------------------------------------------------------- 1 | /** 2 | * SEO component that queries for data with 3 | * Gatsby's useStaticQuery React hook 4 | * 5 | * See: https://www.gatsbyjs.org/docs/use-static-query/ 6 | */ 7 | 8 | import React from 'react' 9 | import PropTypes from 'prop-types' 10 | import Helmet from 'react-helmet' 11 | import { useStaticQuery, graphql } from 'gatsby' 12 | 13 | function SEO({ description, lang, meta, keywords, title, ogImage }) { 14 | const { site } = useStaticQuery( 15 | graphql` 16 | query { 17 | site { 18 | siteMetadata { 19 | title 20 | description 21 | author 22 | } 23 | } 24 | } 25 | ` 26 | ) 27 | const metaDescription = description || site.siteMetadata.description 28 | const defaultImagePath = 'https://www.progressivemass.com/wp-content/uploads/2024/05/scorecard.png' 29 | let ogImagePath 30 | if (ogImage.path) { 31 | ogImagePath = process.env.GATSBY_DOMAIN + ogImage.path 32 | } else { 33 | ogImagePath = defaultImagePath 34 | } 35 | 36 | return ( 37 | 0 104 | ? { 105 | name: `keywords`, 106 | content: keywords.join(`, `), 107 | } 108 | : [] 109 | ) 110 | .concat(meta)} 111 | /> 112 | ) 113 | } 114 | 115 | SEO.defaultProps = { 116 | lang: `en`, 117 | meta: [], 118 | keywords: [], 119 | description: ``, 120 | ogImage: {}, 121 | } 122 | 123 | SEO.propTypes = { 124 | description: PropTypes.string, 125 | lang: PropTypes.string, 126 | meta: PropTypes.arrayOf(PropTypes.object), 127 | keywords: PropTypes.arrayOf(PropTypes.string), 128 | title: PropTypes.string.isRequired, 129 | ogImage: PropTypes.object, 130 | } 131 | 132 | export default SEO 133 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "progressive_mass_legislator_scorecard", 3 | "description": "Progressive Massachusetts Legislator Scorecard", 4 | "version": "1.0.0", 5 | "author": "Alex Holachek", 6 | "dependencies": { 7 | "@babel/compat-data": "^7.21.4", 8 | "@babel/core": "^7.0.0", 9 | "babel-eslint": "^10.0.0", 10 | "babel-plugin-styled-components": "^2.1.4", 11 | "bootstrap": "^4.5.0", 12 | "caniuse-lite": "^1.0.30001717", 13 | "gatsby": "^3.0.0", 14 | "gatsby-image": "^2.4.13", 15 | "gatsby-plugin-google-analytics": "^3.0.0", 16 | "gatsby-plugin-manifest": "^3.0.0", 17 | "gatsby-plugin-offline": "^3.2.18", 18 | "gatsby-plugin-open-graph-images": "^0.1.8", 19 | "gatsby-plugin-prefetch-google-fonts": "^1.4.3", 20 | "gatsby-plugin-react-helmet": "^4.0.0", 21 | "gatsby-plugin-robots-txt": "^1.8.0", 22 | "gatsby-plugin-sass": "^4.0.0", 23 | "gatsby-plugin-sharp": "^3.0.0", 24 | "gatsby-plugin-sitemap": "^3.0.0", 25 | "gatsby-plugin-styled-components": "^5.0.0", 26 | "gatsby-source-filesystem": "^3.0.0", 27 | "gatsby-transformer-json": "^3.0.0", 28 | "gatsby-transformer-sharp": "^3.0.0", 29 | "jquery": "1.9.1", 30 | "popper.js": "^1.16.1", 31 | "prop-types": "^15.8.1", 32 | "query-string": "^6.13.1", 33 | "react": "^16.13.1", 34 | "react-dom": "^16.13.1", 35 | "react-helmet": "^5.2.1", 36 | "react-is": "^16.8.0", 37 | "react-lazyload": "^2.6.9", 38 | "react-responsive-tabs": "^3.3.0", 39 | "react-sticky": "^6.0.3", 40 | "react-sticky-table": "^3.0.0", 41 | "react-tippy": "1.4.0", 42 | "sass": "^1.61.0", 43 | "styled-components": "^5.3.9", 44 | "typescript": "^5.8.3", 45 | "webpack": "^5.99.9" 46 | }, 47 | "devDependencies": { 48 | "axios": "^0.21.2", 49 | "axios-curlirize": "^1.1.0", 50 | "csv": "^5.1.1", 51 | "dotenv": "^7.0.0", 52 | "eslint": "8.37.0", 53 | "eslint-config-prettier": "8.8.0", 54 | "eslint-plugin-import": "2.27.5", 55 | "eslint-plugin-jsx-a11y": "6.7.1", 56 | "eslint-plugin-react": "7.32.2", 57 | "express": "^4.18.2", 58 | "firebase-tools": "^8.4.3", 59 | "fs-extra": "^7.0.1", 60 | "normalize-strings": "^1.1.1", 61 | "prettier": "2.8.7", 62 | "string-similarity": "^3.0.0" 63 | }, 64 | "keywords": [ 65 | "gatsby" 66 | ], 67 | "license": "MIT", 68 | "scripts": { 69 | "build": "yarn run build-data && gatsby build", 70 | "clean": "gatsby clean", 71 | "develop": "gatsby develop", 72 | "format": "prettier --write \"src/**/*.{js,jsx}\"", 73 | "lint": "eslint \"src/**/*.{js,jsx}\" --quiet", 74 | "start": "npm run develop", 75 | "serve": "gatsby serve", 76 | "gatsby:debug": "node --nolazy --inspect-brk node_modules/gatsby/dist/bin/gatsby develop", 77 | "build-data": "node --unhandled-rejections=warn processData/downloadAndBuildData/index.js", 78 | "serve:functions": "firebase serve", 79 | "download-social-images:dev": "node test/downloadSocialImages.js", 80 | "download-social-images:staging": "STAGING=true node test/downloadSocialImages.js", 81 | "download-social-images:prod": "PRODUCTION=true node test/downloadSocialImages.js", 82 | "deploy:functions:prod": "firebase deploy --only functions -P prod", 83 | "deploy:prod": "yarn run build && firebase deploy --only hosting -P prod", 84 | "deploy:staging": "STAGING=true yarn run build && firebase deploy --only hosting -P staging" 85 | }, 86 | "repository": { 87 | "type": "git", 88 | "url": "https://github.com/ProgressiveMass/legislator-scorecard" 89 | }, 90 | "bugs": { 91 | "url": "https://github.com/ProgressiveMass/legislator-scorecard/issues" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/pages/all-legislators/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useStaticQuery, graphql } from 'gatsby' 3 | import Tabs from 'react-responsive-tabs' 4 | import LegislatorList from './LegislatorList' 5 | import Layout from '../../components/layout' 6 | import ListPageHeading from '../../components/ListPageHeading' 7 | 8 | // Ideally, these should not be hard-coded here. We should fix this some day. 9 | const mostRecentYear = 2023 10 | const mostRecentSessionNumber = '193rd' 11 | 12 | export const legislatorQuery = graphql` 13 | { 14 | dataJson { 15 | _2023 { 16 | houseVotes { 17 | id 18 | score 19 | recordedVotePercentage 20 | } 21 | senateVotes { 22 | id 23 | score 24 | recordedVotePercentage 25 | } 26 | } 27 | } 28 | allSenateLegislatorsJson { 29 | edges { 30 | node { 31 | id 32 | name 33 | givenName 34 | familyName 35 | party 36 | district 37 | image 38 | } 39 | } 40 | } 41 | allHouseLegislatorsJson { 42 | edges { 43 | node { 44 | id 45 | name 46 | givenName 47 | familyName 48 | party 49 | district 50 | image 51 | } 52 | } 53 | } 54 | } 55 | ` 56 | 57 | const createLegislatorList = (legislatorArr, voteDataArr) => { 58 | const voteData = voteDataArr.reduce((acc, curr) => { 59 | acc[curr.id] = curr 60 | return acc 61 | }, {}) 62 | return legislatorArr 63 | .map(({ node }) => node) 64 | .map((data) => { 65 | return { 66 | ...data, 67 | name: [data.familyName, data.givenName].join(', '), 68 | } 69 | }) 70 | .map((data) => { 71 | const relevantVoteData = voteData[data.id] 72 | return { 73 | ...data, 74 | ...relevantVoteData, 75 | } 76 | }) 77 | } 78 | 79 | const processQuery = ({ allHouseLegislatorsJson, allSenateLegislatorsJson, dataJson }) => { 80 | return { 81 | senators: createLegislatorList( 82 | allSenateLegislatorsJson.edges, 83 | dataJson[`_${mostRecentYear}`].senateVotes 84 | ), 85 | houseReps: createLegislatorList( 86 | allHouseLegislatorsJson.edges, 87 | dataJson[`_${mostRecentYear}`].houseVotes 88 | ), 89 | } 90 | } 91 | 92 | const AllLegislators = () => { 93 | const legislatorMetadata = processQuery(useStaticQuery(legislatorQuery)) 94 | const tabItems = [ 95 | { 96 | title: 'House Reps', 97 | component: ( 98 | 103 | ), 104 | }, 105 | { 106 | title: 'Senators', 107 | component: ( 108 | 113 | ), 114 | }, 115 | ].map((t) => { 116 | return { 117 | title: t.title, 118 | getContent: () => t.component, 119 | } 120 | }) 121 | 122 | return ( 123 | 124 |
    125 | All Current MA Legislators 126 |
    127 | 128 |
    129 |
    130 |
    131 | ) 132 | } 133 | 134 | export default AllLegislators 135 | -------------------------------------------------------------------------------- /src/components/legislator/LegislatorMetadata.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Rating from './Rating' 4 | import defaultPhoto from '../../images/default-photo.jpg' 5 | 6 | const LegislatorMetadata = (props) => { 7 | return ( 8 |
    9 |
    10 |
    11 |
    12 |
    13 | {props.chamber === 'senate' ? 'Senator' : 'Representative'} 14 |
    15 |

    16 | {props.data.name} 17 |

    18 |
    19 |
    20 | {props.data.image ? ( 21 | {'Photo { 26 | if ( 27 | e.target.src !== 28 | window.location.origin + defaultPhoto 29 | ) { 30 | e.target.src = defaultPhoto 31 | } 32 | }} 33 | /> 34 | ) : null} 35 |
    36 | 37 |
    38 |
    39 | {props.data.party} Party 40 |
    41 |
    {props.data.district}
    42 | {props.data.url ? ( 43 | 48 | ) : null} 49 |
    50 |
    51 |

    54 | Contact {props.legislatorTitle} {props.familyName}: 55 |

    56 |
    57 | 62 | {props.data.phone && ( 63 |
    64 | {props.data.phone} 65 |
    66 | )} 67 | 75 |
    76 |
    77 |
    78 |
    79 |
    80 | 86 |
    87 |
    88 |
    89 |
    90 | ) 91 | } 92 | 93 | LegislatorMetadata.propTypes = { 94 | data: PropTypes.object.isRequired, 95 | chamber: PropTypes.string.isRequired, 96 | legislatorName: PropTypes.string.isRequired, 97 | rating: PropTypes.object.isRequired, 98 | } 99 | 100 | export default LegislatorMetadata 101 | -------------------------------------------------------------------------------- /src/styles/_bootstrap-overrides.scss: -------------------------------------------------------------------------------- 1 | .progress-bar { 2 | text-align: left; 3 | } 4 | 5 | .font-weight-normal { 6 | font-weight: normal !important; 7 | } 8 | 9 | .progress { 10 | position: relative; 11 | background-color: rgba(217, 83, 79, 0.8); 12 | } 13 | .progress__text-container { 14 | text-align: center; 15 | position: absolute; 16 | top: 50%; 17 | color: white; 18 | z-index: 5; 19 | width: 100%; 20 | } 21 | 22 | .progress__text-container--right { 23 | text-align: right; 24 | font-weight: light; 25 | } 26 | .nav-link { 27 | font-family: $headings-font-family; 28 | position: relative; 29 | color: $body-color; 30 | font-size: 1.4rem; 31 | font-weight: bold; 32 | padding: 0.5rem 0 0.25rem 0; 33 | margin-right: 1rem; 34 | 35 | @include media-breakpoint-down(sm) { 36 | font-size: 1rem; 37 | } 38 | 39 | &.active { 40 | color: $primary; 41 | border-bottom: 0.4rem solid $primary; 42 | } 43 | 44 | &.disabled { 45 | color: $gray-500; 46 | } 47 | 48 | &:not(.active):not(disabled):hover, 49 | &:not(.active):not(disabled):focus { 50 | border-bottom: 0.4rem solid fade-out($light, 0.5); 51 | } 52 | } 53 | 54 | .badge-gold { 55 | color: $white; 56 | background-color: $orange; 57 | &:hover, 58 | &:focus, 59 | &:active { 60 | color: $white; 61 | } 62 | } 63 | 64 | .badge-purple { 65 | color: $white; 66 | background-color: $purple; 67 | &:hover, 68 | &:focus, 69 | &:active { 70 | color: $white; 71 | } 72 | } 73 | 74 | .badge-cyan { 75 | color: $white; 76 | background-color: $cyan; 77 | &:hover, 78 | &:focus, 79 | &:active { 80 | color: $white; 81 | } 82 | } 83 | 84 | .badge-yellow { 85 | color: rgb(133 77 14 ); 86 | background-color: rgb(254 249 195 ); 87 | 88 | &:hover, 89 | &:focus, 90 | &:active { 91 | color:rgb(133 77 14); 92 | } 93 | } 94 | 95 | .badge-green { 96 | color: $white; 97 | background-color: $green; 98 | &:hover, 99 | &:focus, 100 | &:active { 101 | color: $white; 102 | } 103 | } 104 | 105 | .badge-gray { 106 | color: $black; 107 | background-color: $gray-300; 108 | 109 | &:hover, 110 | &:focus, 111 | &:active { 112 | color: $black; } 113 | } 114 | 115 | .badge-secondary { 116 | color: $gray-900; 117 | background-color: $gray-300; 118 | } 119 | 120 | .badge-danger { 121 | color: $danger; 122 | background-color: hsla(354, 70%, 54%, 0.18); 123 | } 124 | 125 | .table th, 126 | .table td { 127 | padding: 0.75rem; 128 | vertical-align: top; 129 | border-top: 0; 130 | } 131 | 132 | .text-weight-light { 133 | font-weight: 300 !important; 134 | } 135 | 136 | .card { 137 | @extend .white-background; 138 | } 139 | 140 | /* ========================================================================== 141 | responsive tables 142 | ========================================================================== */ 143 | /* Stack rows vertically on small screens */ 144 | @media (max-width: map-get($grid-breakpoints, md)) { 145 | /* Hide column labels */ 146 | thead tr { 147 | position: absolute; 148 | top: -9999em; 149 | left: -9999em; 150 | } 151 | tr { 152 | border: 1px solid $gray-200 !important; 153 | border-bottom: 0; 154 | } 155 | 156 | /* Get table cells to act like rows */ 157 | tr, 158 | td { 159 | display: block; 160 | } 161 | td { 162 | border: none; 163 | border-bottom: 1px solid $gray-200; 164 | /* Leave a space for data labels */ 165 | padding-left: 50%; 166 | // override widths set on the cells for big-screen viewing 167 | width: 100% !important; 168 | &:last-of-type { 169 | border-bottom: none; 170 | } 171 | } 172 | /* Add data labels */ 173 | td:before { 174 | content: attr(data-label); 175 | display: block; 176 | font-weight: bold; 177 | width: 100%; 178 | margin-bottom: 0.3rem; 179 | } 180 | } 181 | 182 | .lead { 183 | font-weight: normal; 184 | } 185 | 186 | .text-danger { 187 | color: #e27872 !important; 188 | } 189 | -------------------------------------------------------------------------------- /src/components/legislator/LegislatorTable.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { StickyContainer, Sticky } from 'react-sticky' 3 | import getTagData from './tagMap' 4 | import styled from 'styled-components' 5 | import { QUERIES } from '../../utilities' 6 | 7 | const StyledTags = styled.ul` 8 | @media ${QUERIES.tabletAndSmaller} { 9 | display: flex; 10 | flex-wrap: wrap; 11 | gap: 0.5rem; 12 | padding: 0 0.5rem; 13 | } 14 | ` 15 | const TagFilterList = ({ tags, tagFilter, toggleFilter }) => { 16 | return ( 17 | <> 18 | Filter Bills By Topic: 19 | 20 | {tags.map((t) => { 21 | let badgeClass = 'badge-light' 22 | if (!tagFilter || tagFilter === t) { 23 | badgeClass = getTagData(t).badge 24 | } 25 | return ( 26 |
  • 27 | 34 |
  • 35 | ) 36 | })} 37 |
    38 | 39 | ) 40 | } 41 | 42 | const RowTags = ({ tags = [], toggleFilter }) => ( 43 |
      44 | {tags.map((t, index) => { 45 | return ( 46 |
    • 47 | 55 |
    • 56 | ) 57 | })} 58 |
    59 | ) 60 | 61 | const filterRows = (data, tagFilter) => { 62 | if (!tagFilter) return data 63 | return data.filter((d) => d.tags.indexOf(tagFilter) > -1) 64 | } 65 | 66 | const EmptyView = () => { 67 | return ( 68 |
    69 |

    No Data Available

    70 |
    71 | ) 72 | } 73 | 74 | const LegislatorTable = ({ 75 | head, 76 | description, 77 | title, 78 | rowComponent: RowComponent, 79 | rowData, 80 | isCurrentYear, 81 | familyName, 82 | }) => { 83 | const [tagFilter, setTagFilter] = useState('') 84 | 85 | const toggleFilter = (tag) => setTagFilter(tagFilter === tag ? '' : tag) 86 | 87 | const tags = Array?.from( 88 | new Set(rowData?.map((c) => c.tags).reduce((acc, curr) => acc.concat(curr), [])) 89 | ).sort() 90 | 91 | const filteredData = filterRows(rowData, tagFilter) 92 | 93 | if (!filteredData?.length) return 94 | 95 | return ( 96 |
    97 |

    {title}

    98 | {description && ( 99 |
    100 |

    {description}

    101 |
    102 | )} 103 | 104 |
    105 |
    106 | 107 |
    108 |
    109 | 110 | {({ style: stickyStyle }) => {head}} 111 | 112 | {filteredData.map((d, index) => ( 113 | } 119 | /> 120 | ))} 121 | 122 |
    123 |
    124 |
    125 | ) 126 | } 127 | 128 | export default LegislatorTable 129 | -------------------------------------------------------------------------------- /functions/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | // Required for certain syntax usages 4 | "ecmaVersion": 6 5 | }, 6 | "plugins": [ 7 | "promise" 8 | ], 9 | "extends": "eslint:recommended", 10 | "rules": { 11 | // Removed rule "disallow the use of console" from recommended eslint rules 12 | "no-console": "off", 13 | 14 | // Removed rule "disallow multiple spaces in regular expressions" from recommended eslint rules 15 | "no-regex-spaces": "off", 16 | 17 | // Removed rule "disallow the use of debugger" from recommended eslint rules 18 | "no-debugger": "off", 19 | 20 | // Removed rule "disallow unused variables" from recommended eslint rules 21 | "no-unused-vars": "off", 22 | 23 | // Removed rule "disallow mixed spaces and tabs for indentation" from recommended eslint rules 24 | "no-mixed-spaces-and-tabs": "off", 25 | 26 | // Removed rule "disallow the use of undeclared variables unless mentioned in /*global */ comments" from recommended eslint rules 27 | "no-undef": "off", 28 | 29 | // Warn against template literal placeholder syntax in regular strings 30 | "no-template-curly-in-string": 1, 31 | 32 | // Warn if return statements do not either always or never specify values 33 | "consistent-return": 1, 34 | 35 | // Warn if no return statements in callbacks of array methods 36 | "array-callback-return": 1, 37 | 38 | // Require the use of === and !== 39 | "eqeqeq": 2, 40 | 41 | // Disallow the use of alert, confirm, and prompt 42 | "no-alert": 2, 43 | 44 | // Disallow the use of arguments.caller or arguments.callee 45 | "no-caller": 2, 46 | 47 | // Disallow null comparisons without type-checking operators 48 | "no-eq-null": 2, 49 | 50 | // Disallow the use of eval() 51 | "no-eval": 2, 52 | 53 | // Warn against extending native types 54 | "no-extend-native": 1, 55 | 56 | // Warn against unnecessary calls to .bind() 57 | "no-extra-bind": 1, 58 | 59 | // Warn against unnecessary labels 60 | "no-extra-label": 1, 61 | 62 | // Disallow leading or trailing decimal points in numeric literals 63 | "no-floating-decimal": 2, 64 | 65 | // Warn against shorthand type conversions 66 | "no-implicit-coercion": 1, 67 | 68 | // Warn against function declarations and expressions inside loop statements 69 | "no-loop-func": 1, 70 | 71 | // Disallow new operators with the Function object 72 | "no-new-func": 2, 73 | 74 | // Warn against new operators with the String, Number, and Boolean objects 75 | "no-new-wrappers": 1, 76 | 77 | // Disallow throwing literals as exceptions 78 | "no-throw-literal": 2, 79 | 80 | // Require using Error objects as Promise rejection reasons 81 | "prefer-promise-reject-errors": 2, 82 | 83 | // Enforce “for” loop update clause moving the counter in the right direction 84 | "for-direction": 2, 85 | 86 | // Enforce return statements in getters 87 | "getter-return": 2, 88 | 89 | // Disallow await inside of loops 90 | "no-await-in-loop": 2, 91 | 92 | // Disallow comparing against -0 93 | "no-compare-neg-zero": 2, 94 | 95 | // Warn against catch clause parameters from shadowing variables in the outer scope 96 | "no-catch-shadow": 1, 97 | 98 | // Disallow identifiers from shadowing restricted names 99 | "no-shadow-restricted-names": 2, 100 | 101 | // Enforce return statements in callbacks of array methods 102 | "callback-return": 2, 103 | 104 | // Require error handling in callbacks 105 | "handle-callback-err": 2, 106 | 107 | // Warn against string concatenation with __dirname and __filename 108 | "no-path-concat": 1, 109 | 110 | // Prefer using arrow functions for callbacks 111 | "prefer-arrow-callback": 1, 112 | 113 | // Return inside each then() to create readable and reusable Promise chains. 114 | // Forces developers to return console logs and http calls in promises. 115 | "promise/always-return": 2, 116 | 117 | //Enforces the use of catch() on un-returned promises 118 | "promise/catch-or-return": 2, 119 | 120 | // Warn against nested then() or catch() statements 121 | "promise/no-nesting": 1 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /processData/miscDataParsingScripts/addOpenStatesIdsToCosponsorship.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const axios = require('axios') 3 | const parse = require('csv-parse/lib/sync') 4 | const stringify = require('csv-stringify/lib/sync') 5 | const normalize = require('normalize-strings') 6 | const stringSimilarity = require('string-similarity') 7 | require('dotenv').config() 8 | 9 | // change this 10 | const fileName = `2019_Progressive_Mass_Data - Sponsorship.csv` 11 | 12 | const houseId = 'ocd-organization/ca38ad9c-c3d5-4c4f-bc2f-d885218ed802' 13 | const senateId = 'ocd-organization/1a75ab3a-669b-43fe-ac8d-31a2d6923d9a' 14 | 15 | function normalizeName(name) { 16 | return name 17 | .toLowerCase() 18 | .trim() 19 | .replace(/[.-]/g, '') 20 | } 21 | 22 | function getNormalizedLastName(name) { 23 | return normalizeName(name.split(',')[0]) 24 | } 25 | 26 | function createNameKey(name) { 27 | return normalizeName(normalize(name)).replace(/[\s,'"]/g, '') 28 | } 29 | const makeQuery = names => ` 30 | query getLegislatorIds($organization: String ) { 31 | ${names.map((name, index) => { 32 | const key = createNameKey(name) 33 | if (!key) return '' 34 | return ` 35 | ${key} : people(name: "${getNormalizedLastName( 36 | name 37 | )}", memberOf: $organization, first: 10) { 38 | edges { 39 | node { 40 | id 41 | name 42 | } 43 | } 44 | } 45 | ` 46 | })} 47 | } 48 | ` 49 | 50 | const makeRequest = ({ organization, names }) => { 51 | const query = makeQuery(names) 52 | fs.writeFileSync(`${__dirname}/query.txt`, query) 53 | return axios.post( 54 | 'https://openstates.org/graphql', 55 | { 56 | query: query, 57 | variables: { 58 | organization, 59 | }, 60 | }, 61 | { 62 | headers: { 63 | 'X-API-KEY': process.env.OPENSTATES_API_KEY, 64 | }, 65 | } 66 | ) 67 | } 68 | 69 | const processData = async fileName => { 70 | const fileContents = fs.readFileSync(`${__dirname}/../${fileName}`, 'utf8') 71 | 72 | const csvContents = parse(fileContents) 73 | const nameRow = csvContents[0] 74 | 75 | const newRow = [...new Array(nameRow.length)].map(n => '') 76 | 77 | let senateData = await makeRequest({ 78 | organization: senateId, 79 | names: nameRow.slice(3).filter(Boolean), 80 | }) 81 | senateData = senateData.data.data 82 | 83 | let houseData = await makeRequest({ 84 | organization: houseId, 85 | names: nameRow.slice(3), 86 | }) 87 | 88 | houseData = houseData.data.data 89 | 90 | Object.keys(senateData).forEach((key, i) => { 91 | if (senateData[key].edges.length === 0) { 92 | senateData[key] = houseData[key] 93 | } 94 | }) 95 | 96 | const allData = senateData 97 | 98 | const newRowWithIds = newRow.map((_, index) => { 99 | if (index <= 2) return '' 100 | const name = nameRow[index].trim() 101 | const personData = allData[createNameKey(name)] 102 | if (!personData) return '' 103 | try { 104 | const result = personData.edges 105 | if (result.length === 1) { 106 | console.log(`matching ${result[0].node.name} with ${nameRow[index]}`) 107 | return result[0].node.id 108 | } else if (result.length > 1) { 109 | const bestMatch = stringSimilarity.findBestMatch( 110 | name, 111 | result.map(({ node: { name } }) => name) 112 | ).bestMatch 113 | if (bestMatch.rating > 0.6) { 114 | // console.log( 115 | // `Via string similarity metrics, the best match for ${name} was: ${ 116 | // bestMatch.target 117 | // }` 118 | // ) 119 | return result.find(({ node: { name } }) => name === bestMatch.target) 120 | .node.id 121 | } else { 122 | return 'NOT FOUND' 123 | } 124 | } else if (!result.length) { 125 | console.log(nameRow[index], 'had no matches') 126 | return 'NOT FOUND' 127 | } 128 | } catch (e) { 129 | debugger // eslint-disable-line 130 | } 131 | }) 132 | 133 | fs.writeFileSync( 134 | `${fileName}-with-id.csv`, 135 | stringify([newRowWithIds].concat(csvContents)) 136 | ) 137 | } 138 | 139 | processData(fileName) 140 | -------------------------------------------------------------------------------- /src/components/legislator/Rating.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import ProgressBarWContext from '../progressBar' 4 | import ProgressBar from '../progressBar/ProgressBar' 5 | import { getSessionNumber } from '../../utilities' 6 | 7 | const Rating = (props) => { 8 | const renderMedians = () => { 9 | return ( 10 |
    11 |
    12 |
    13 | Median {props.chamber === 'senate' ? 'Sen.' : 'House'} Democrat 14 |
    15 | 16 |
    17 |
    18 |
    19 | Median {props.chamber === 'senate' ? 'Sen.' : 'House'} Republican 20 |
    21 | 24 |
    25 |
    26 | ) 27 | } 28 | 29 | const renderVoteSection = () => { 30 | const fontSize = Math.min(34 - props.familyName.length, 18) 31 | const sessionNumber = getSessionNumber(props.rating.votes.cumulative.term) 32 | return ( 33 |
    34 |
    35 |

    Vote information

    36 |

    37 | {`Voted with the progressive position ${props.rating.votes.score} percent of the time.`} 38 | {`The median democrat progressive rating was ${props.rating.votes.cumulative.median.democrat} percent.`} 39 | {`The median republican progressive rating was ${props.rating.votes.cumulative.median.republican} percent.`} 40 |

    41 |
    42 | 43 |
    44 |
    45 | 48 | {props.title}  49 | {props.familyName}'s votes  50 | 51 | ({props.rating.votes.cumulative.term}) 52 | 53 | 54 | 59 |
    60 | {renderMedians()} 61 |
    62 |
    63 | ) 64 | } 65 | 66 | const renderCosponsorshipSection = () => { 67 | if (isNaN(props.rating.sponsorship.legislator)) return null 68 | 69 | return ( 70 |
    71 |

    78 | {props.title}  79 | {props.familyName} cosponsored 80 |

    81 |
    84 | 3 87 | ? 'text-primary' 88 | : 'text-danger' 89 | }`}> 90 | {props.rating.sponsorship.legislator} 91 | 92 |   93 | 94 | progressive{' '} 95 | {props.rating.sponsorship.legislator === 1 ? 'bill' : 'bills'} 96 | 97 |
    98 |
    99 | out of  100 | {props.rating.sponsorship.total} 101 |  featured by Prog. Mass for{' '} 102 | {props.rating.sponsorship.cumulative.term}  103 |
    104 |
    105 | ) 106 | } 107 | 108 | if (!props.rating) return null 109 | 110 | return ( 111 |
    112 |

    Progressive Ranking Summary

    113 | {renderVoteSection()} 114 | {renderCosponsorshipSection()} 115 |
    116 | ) 117 | } 118 | 119 | export default Rating 120 | Rating.propTypes = { 121 | rating: PropTypes.object.isRequired, 122 | chamber: PropTypes.string.isRequired, 123 | legislatorName: PropTypes.string.isRequired, 124 | } 125 | -------------------------------------------------------------------------------- /gatsbyNodeHelper/index.js: -------------------------------------------------------------------------------- 1 | const legislationData = require('../src/data/legislation.json') 2 | const houseLegislators = require('../src/data/house_legislators.json') 3 | const senateLegislators = require('../src/data/senate_legislators.json') 4 | const buildVoteCumulativeData = require('./buildVoteCumulativeData') 5 | const buildSponsorshipCumulativeData = require('./buildSponsorshipCumulativeData') 6 | 7 | const voteSummaryYear = 2023 8 | const sponsorshipSummaryYear = 2025 9 | 10 | const legislatorData = { 11 | senate: senateLegislators, 12 | house: houseLegislators, 13 | } 14 | 15 | const medianVoteData = buildVoteCumulativeData(voteSummaryYear, legislatorData) 16 | 17 | const medianSponsorshipData = buildSponsorshipCumulativeData( 18 | sponsorshipSummaryYear, 19 | legislatorData 20 | ) 21 | 22 | // helpers 23 | const getLegislatorVotesEntry = ({ year, chamber, legislatorId }) => 24 | legislationData[year][`${chamber}Votes`].find( 25 | entry => entry && entry.id === legislatorId 26 | ) 27 | 28 | const getLegislatorSponsorshipEntry = ({ year, legislatorId }) => 29 | legislationData[year].sponsorship.find(entry => entry.id === legislatorId) 30 | 31 | // main function 32 | const createPageDataStruct = ({ chamber, legislatorId }) => { 33 | const legislator = legislatorData[chamber].find( 34 | data => data && data.id === legislatorId 35 | ) 36 | const pageData = { 37 | legislator, 38 | } 39 | 40 | pageData.data = Object.keys(legislationData).map(year => { 41 | const termData = { 42 | term: `${year}-${parseInt(year) + 1}`, 43 | } 44 | //sponsorship 45 | const progMassSponsoredBills = legislationData[year].sponsoredBills 46 | const legislatorSponsorshipEntry = getLegislatorSponsorshipEntry({ 47 | year, 48 | legislatorId, 49 | }) 50 | termData.isCurrentSponsorshipYear = year == sponsorshipSummaryYear 51 | if (legislatorSponsorshipEntry === undefined) { 52 | termData.sponsorship = [] 53 | } else { 54 | const legislatorSponsorship = legislatorSponsorshipEntry.data 55 | 56 | termData.sponsorship = Object.keys(legislatorSponsorship).map(billNum => { 57 | const billKey = !billNum.match('/') 58 | ? billNum 59 | : chamber === 'senate' 60 | ? billNum.match(/SD?\d+/)[0] 61 | : billNum.match(/HD?\d+/)[0] 62 | const billData = progMassSponsoredBills[billKey] 63 | if (!billData) { 64 | throw new Error( 65 | `no bill data for ${billNum} found! (this means something is really wrong)` 66 | ) 67 | } 68 | return { 69 | ...billData, 70 | yourLegislator: legislatorSponsorship[billNum], 71 | } 72 | }) 73 | } 74 | 75 | // votes 76 | if (legislationData[year][`${chamber}Votes`]) { 77 | const legislatorVotesEntry = getLegislatorVotesEntry({ 78 | year, 79 | chamber, 80 | legislatorId, 81 | }) 82 | if (legislatorVotesEntry === undefined) { 83 | termData.votes = [] 84 | } else { 85 | const legislatorVotes = legislatorVotesEntry.data 86 | 87 | termData.votes = Object.keys(legislatorVotes).map(rollCallNumber => { 88 | return { 89 | ...legislationData[year][`${chamber}Bills`][rollCallNumber], 90 | yourLegislator: legislatorVotes[rollCallNumber], 91 | } 92 | }) 93 | termData.score = legislatorVotesEntry.score 94 | termData.recordedVotePercentage = 95 | legislatorVotesEntry.recordedVotePercentage 96 | } 97 | } 98 | return termData 99 | }) 100 | 101 | const votes = getLegislatorVotesEntry({ 102 | year: voteSummaryYear, 103 | chamber, 104 | legislatorId, 105 | }) 106 | 107 | const voteSummary = { 108 | cumulative: medianVoteData[chamber], 109 | recordedVotePercentage: votes && votes.recordedVotePercentage, 110 | score: votes && votes.score, 111 | } 112 | 113 | const sponsorship = getLegislatorSponsorshipEntry({ 114 | year: sponsorshipSummaryYear, 115 | chamber, 116 | legislatorId, 117 | }) 118 | const sponsorshipSummary = { 119 | cumulative: medianSponsorshipData, 120 | legislator: sponsorship && sponsorship.score, 121 | total: sponsorship && Object.keys(sponsorship.data).length, 122 | } 123 | 124 | pageData.rating = { 125 | votes: voteSummary, 126 | sponsorship: sponsorshipSummary, 127 | } 128 | return pageData 129 | } 130 | 131 | module.exports = createPageDataStruct 132 | -------------------------------------------------------------------------------- /src/styles/_general.scss: -------------------------------------------------------------------------------- 1 | $box-shadow-light: 0 2px 10px rgba(0, 0, 0, 0.07); 2 | $box-shadow-medium: 0 0 30px rgba(0, 0, 0, 0.15); 3 | 4 | @import url('https://fonts.googleapis.com/css?family=Montserrat:400,500,700'); 5 | 6 | .h1, 7 | .h2, 8 | .h3, 9 | .h4, 10 | .h5, 11 | .h6, 12 | h1, 13 | h2, 14 | h3, 15 | h4, 16 | h5, 17 | h6 { 18 | font-weight: 800; 19 | } 20 | 21 | .flex-grow { 22 | flex-grow: 1; 23 | } 24 | 25 | .module-container { 26 | padding-left: 1rem; 27 | padding-right: 1rem; 28 | @include media-breakpoint-up(md) { 29 | padding-left: 2rem; 30 | padding-right: 2rem; 31 | } 32 | max-width: 1100px; 33 | margin: auto; 34 | } 35 | 36 | .module-container--full-width-on-small { 37 | @include media-breakpoint-down(sm) { 38 | padding-left: 0; 39 | padding-right: 0; 40 | } 41 | } 42 | 43 | .module-container--padded { 44 | @include media-breakpoint-up(lg) { 45 | padding-left: 2rem; 46 | padding-right: 2rem; 47 | } 48 | } 49 | 50 | .label, 51 | label { 52 | font-weight: bold; 53 | text-transform: uppercase; 54 | font-size: 0.9rem; 55 | } 56 | 57 | button { 58 | cursor: pointer; 59 | } 60 | 61 | label { 62 | display: block; 63 | } 64 | 65 | p a { 66 | font-weight: bold; 67 | } 68 | 69 | ul { 70 | padding-left: 1rem; 71 | } 72 | 73 | footer { 74 | @extend .clearfix; 75 | background: $body-color; 76 | color: $white; 77 | font-size: 1.02rem; 78 | font-family: $headings-font-family; 79 | 80 | a { 81 | color: lighten($primary, 20%); 82 | &:hover, 83 | &:focus { 84 | color: lighten($primary, 15%); 85 | } 86 | } 87 | 88 | img { 89 | max-height: 100px; 90 | max-width: 100%; 91 | } 92 | } 93 | 94 | .dark-tint { 95 | background: fade-out(black, 0.88); 96 | @extend .heading-font; 97 | font-size: 1.2rem; 98 | padding: 1rem 0; 99 | p { 100 | margin-bottom: 0; 101 | } 102 | } 103 | 104 | .text-lg { 105 | font-size: 1.3rem; 106 | } 107 | 108 | .font-weight-light { 109 | font-weight: 300 !important; 110 | } 111 | 112 | thead { 113 | background: #fff; 114 | @extend .label; 115 | width: 100%; 116 | } 117 | 118 | .table--top-row-fixed { 119 | display: block; 120 | } 121 | .table--top-row-fixed thead { 122 | display: table; 123 | } 124 | 125 | .tinted-background { 126 | background-color: $gray-200; 127 | padding-top: 1rem; 128 | padding-bottom: 1rem; 129 | } 130 | 131 | .heading-font { 132 | font-family: $headings-font-family; 133 | } 134 | 135 | .badge a { 136 | color: #fff; 137 | 138 | &:focus, 139 | &:hover { 140 | color: #fff; 141 | } 142 | } 143 | 144 | .vertical-center { 145 | top: 50%; 146 | transform: translateY(-50%); 147 | position: relative; 148 | } 149 | @keyframes pulse_animation { 150 | 0% { 151 | opacity: 1; 152 | } 153 | 154 | 100% { 155 | opacity: 0.5; 156 | } 157 | } 158 | 159 | .loading { 160 | min-height: 50vh; 161 | @include media-breakpoint-up(md) { 162 | h1 { 163 | font-size: 6rem; 164 | animation-name: pulse_animation; 165 | animation-duration: 1.5s; 166 | transform-origin: 50% 50%; 167 | animation-iteration-count: infinite; 168 | animation-timing-function: linear; 169 | animation-direction: alternate; 170 | } 171 | text-align: center; 172 | } 173 | } 174 | 175 | .btn-icon { 176 | background-color: #fff; 177 | padding: 0.5rem 0.25rem; 178 | 179 | &:focus, 180 | &:hover { 181 | background-color: fade-out(#000, 0.95); 182 | } 183 | } 184 | 185 | .btn-icon--basic { 186 | padding: 0; 187 | &:focus, 188 | &:hover, 189 | &:active { 190 | background-color: transparent; 191 | box-shadow: none; 192 | img { 193 | background: fade-out($info, 0.8); 194 | } 195 | } 196 | 197 | img { 198 | border-radius: 100px; 199 | } 200 | } 201 | 202 | .rotated { 203 | transform: rotate(180deg); 204 | display: inline-block; 205 | } 206 | 207 | .table-clickable-rows tr { 208 | cursor: pointer; 209 | } 210 | 211 | .muted-link { 212 | color: $gray-600; 213 | text-decoration: underline; 214 | &:hover, 215 | &:focus { 216 | color: $gray-700; 217 | text-decoration: underline; 218 | } 219 | } 220 | 221 | small { 222 | font-family: $font-family-sans-serif !important; 223 | } 224 | 225 | .white-background { 226 | background: $white; 227 | padding: 1rem 0.75rem; 228 | @include media-breakpoint-up(sm) { 229 | padding: 2rem; 230 | } 231 | } 232 | 233 | .font-weight-800 { 234 | font-weight: 800 !important; 235 | } 236 | 237 | .readable-measure { 238 | max-width: 800px; 239 | margin: auto; 240 | } 241 | 242 | .rounded { 243 | border-radius: 50px !important; 244 | } 245 | -------------------------------------------------------------------------------- /src/pages/landing/SearchForm.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { navigate, useStaticQuery, graphql } from 'gatsby' 3 | import axios from 'axios' 4 | import { getLegislatorUrlParams } from '../../utilities' 5 | 6 | const randomLocations = [ 7 | { 8 | street: '64 Weir St', 9 | city: 'Taunton', 10 | }, 11 | 12 | { 13 | street: '50 Nauset Road', 14 | city: 'Eastham', 15 | }, 16 | { 17 | street: ' 820 Front St', 18 | city: 'Chicopee', 19 | }, 20 | { 21 | street: '304 Dutton Street', 22 | city: 'Lowell', 23 | }, 24 | { 25 | street: '15 Oak Bluffs Ave', 26 | city: 'Oak Bluffs', 27 | }, 28 | { 29 | street: '2101 Commonwealth Avenue', 30 | city: 'Boston', 31 | }, 32 | { 33 | street: '2101 Commonwealth Avenue', 34 | city: 'Boston', 35 | }, 36 | { 37 | street: '908 N Montello St', 38 | city: 'Brockton', 39 | }, 40 | { street: '1 Skyline Dr', city: 'Worcester' }, 41 | ] 42 | 43 | const SearchForm = () => { 44 | const [loading, setLoading] = React.useState(false) 45 | const [street, setStreet] = React.useState('') 46 | const [city, setCity] = React.useState('') 47 | 48 | const legislatorNamesAndMemberCodes = useStaticQuery( 49 | graphql` 50 | { 51 | allSenateLegislatorsJson { 52 | edges { 53 | node { 54 | givenName 55 | familyName 56 | memberCode 57 | } 58 | } 59 | } 60 | allHouseLegislatorsJson { 61 | edges { 62 | node { 63 | givenName 64 | familyName 65 | memberCode 66 | } 67 | } 68 | } 69 | } 70 | ` 71 | ) 72 | const memberCodesToUrls = Object.fromEntries( 73 | legislatorNamesAndMemberCodes.allHouseLegislatorsJson.edges.concat( 74 | legislatorNamesAndMemberCodes.allSenateLegislatorsJson.edges 75 | ) 76 | .map(({ node }) => node) 77 | .map((data) => { 78 | return [ 79 | data.memberCode, 80 | getLegislatorUrlParams(data), 81 | ] 82 | }) 83 | ) 84 | 85 | const randomizeLocation = () => { 86 | const randomLocation = 87 | randomLocations[Math.floor(Math.random() * randomLocations.length)] 88 | setCity(randomLocation.city) 89 | setStreet(randomLocation.street) 90 | } 91 | 92 | const onFormSubmit = (e) => { 93 | e.preventDefault() 94 | setLoading(true) 95 | const address = street + ', ' + city + ', MA' 96 | 97 | return axios 98 | .post(`${process.env.GATSBY_SERVERLESS_ENDPOINT}/local-legislators`, { 99 | address, 100 | }) 101 | .then((response) => { 102 | navigate( 103 | `/legislator/${memberCodesToUrls[ 104 | response.data.senator 105 | ]}?yourRep=${memberCodesToUrls[ 106 | response.data.representative 107 | ]}` 108 | ) 109 | }) 110 | .catch((error) => { 111 | // TODO: better error handling 112 | setLoading(false) 113 | console.error(error) 114 | }) 115 | } 116 | 117 | const onChange = ({ target: { name, value } }) => { 118 | if (name === 'street') { 119 | setStreet(value) 120 | } 121 | if (name === 'city') { 122 | setCity(value) 123 | } 124 | } 125 | 126 | return ( 127 |
    130 |
    131 | 137 |
    138 |
    139 | 149 |
    150 | 151 |
    152 | 162 |
    Massachusetts
    163 |
    164 | 165 |
    166 | 178 |
    179 |
    180 | ) 181 | } 182 | 183 | export default SearchForm 184 | -------------------------------------------------------------------------------- /src/components/legislator/SponsorshipTable.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import styled from 'styled-components' 4 | import InfoPopover from '../InfoPopover' 5 | import LegislatorTable from './LegislatorTable' 6 | import { QUERIES } from '../../utilities' 7 | 8 | const StyledTableRow = styled.tr` 9 | border-bottom: 1px solid #eee; 10 | @media ${QUERIES.tabletAndSmaller} { 11 | margin: 15px; 12 | border-radius: 15px; 13 | background-color: #f9f9f9; 14 | } 15 | ` 16 | 17 | const billWidth = 15 18 | const titleWidth = 25 19 | const statusWidth = 15 20 | const summaryWidth = 45 21 | const summaryWidthCurrentYear = 30 22 | const cosponsoredWidth = 15 23 | 24 | const Cosponsorship = ({ indicator, isCurrentSponsorshipYear }) => { 25 | if (indicator === false) { 26 | if (isCurrentSponsorshipYear) { 27 | return Not Yet 28 | } else { 29 | return No 30 | } 31 | } else if (indicator === true) { 32 | return Yes 33 | } else { 34 | return N/A 35 | } 36 | } 37 | 38 | const SponsorshipRow = ({ 39 | tags, 40 | rowData: { 41 | bill_number, 42 | showPairedDisclaimer, 43 | shorthand_title, 44 | description, 45 | yourLegislator, 46 | houseStatus, 47 | senateStatus 48 | }, 49 | isCurrentYear, 50 | familyName, 51 | }) => { 52 | 53 | return ( 54 | 55 | 56 |
    57 | {bill_number}  58 | {showPairedDisclaimer ? ( 59 | 60 | ) : null} 61 |
    62 | 63 |
    {tags}
    64 | 65 | 66 | 71 | 72 | {isCurrentYear && ( 76 |
    77 | {houseStatus &&
    House: {houseStatus}
    } 78 | {senateStatus &&
    Senate: {senateStatus}
    } 79 |
    80 | )} 81 | 82 |

    {description}

    83 | 84 | 85 | 89 | 90 |
    91 | ) 92 | } 93 | 94 | const description = ( 95 | <> 96 | Cosponsoring legislation is an important way for a legislator to help put momentum behind 97 | certain bills. To learn more about which bills Progressive Mass thinks are most important to 98 | support, you can view{' '} 99 | 104 | our Legislative Agenda 105 | 106 | . 107 | 108 | ) 109 | 110 | const SponsorshipTable = ({ data: { sponsorship, isCurrentSponsorshipYear }, familyName }) => { 111 | return ( 112 | 120 | 121 | Bill 122 | Title 123 | {isCurrentSponsorshipYear && Status} 124 | 125 | Summary from{' '} 126 | 127 | Progressive Mass 128 | 129 | 130 | {familyName} Cosponsored? 131 | 132 | 133 | } 134 | rowComponent={SponsorshipRow} 135 | /> 136 | ) 137 | } 138 | 139 | export default SponsorshipTable 140 | 141 | SponsorshipTable.propTypes = { 142 | data: PropTypes.object.isRequired, 143 | legislatorName: PropTypes.string.isRequired, 144 | } 145 | -------------------------------------------------------------------------------- /src/components/sponsorships/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'gatsby' 3 | import styled from 'styled-components' 4 | import Layout from '../../components/layout' 5 | import LegislatorTable from '../../components/legislator/LegislatorTable' 6 | import ListPageHeading from '../../components/ListPageHeading' 7 | import InfoPopover from '../../components/InfoPopover' 8 | import { QUERIES } from '../../utilities' 9 | 10 | const Container = styled.div` 11 | display: flex; 12 | flex-direction: column; 13 | padding: 0 4rem; 14 | gap: 1rem; 15 | justify-items: center; 16 | align-items: center; 17 | 18 | @media ${QUERIES.tabletAndSmaller} { 19 | padding: 0 2rem; 20 | } 21 | ` 22 | 23 | const StyledRow = styled.tr` 24 | @media ${QUERIES.phoneAndSmaller} { 25 | display: grid; 26 | grid-template-areas: 'title title' 'number status' 'summary summary'; 27 | grid-template-columns: 1fr 1fr; 28 | grid-template-rows: min-content min-content 1fr; 29 | 30 | & > * { 31 | border: none; 32 | } 33 | 34 | ul { 35 | margin: 0; 36 | } 37 | 38 | & > td#number, 39 | & > td#status { 40 | display: flex; 41 | flex-direction: column; 42 | padding-top: 0; 43 | padding-bottom: 0; 44 | } 45 | 46 | & > td#number { 47 | grid-area: number; 48 | } 49 | 50 | & > td#title { 51 | grid-area: title; 52 | } 53 | 54 | & > td#status { 55 | text-align: center; 56 | grid-area: status; 57 | } 58 | 59 | & > td#summary { 60 | grid-area: summary; 61 | } 62 | } 63 | ` 64 | 65 | const billWidth = 15 66 | const titleWidth = 25 67 | const statusWidth = 15 68 | const summaryWidth = 45 69 | 70 | const SponsorshipRow = (props) => { 71 | const { tags, rowData, isCurrentYear, familyName } = props 72 | const { 73 | houseBillNumber, 74 | senateBillNumber, 75 | showPairedDisclaimer, 76 | shorthand_title, 77 | description, 78 | houseStatus, 79 | senateStatus, 80 | } = rowData 81 | 82 | const separator = houseBillNumber && senateBillNumber ? ' / ' : '' 83 | const combinedBillNumber = [houseBillNumber, senateBillNumber].join(separator) 84 | const urlBillNumber = houseBillNumber || senateBillNumber 85 | 86 | return ( 87 | 88 | 89 |
    90 | {combinedBillNumber} 91 |   92 | {showPairedDisclaimer ? ( 93 | 94 | ) : null} 95 |
    96 | 97 |
    {tags}
    98 | 99 | 100 |
    101 | {`${shorthand_title}`} 102 |
    103 | 104 | 108 |
    109 | {houseStatus &&
    House: {houseStatus}
    } 110 | {senateStatus &&
    Senate: {senateStatus}
    } 111 |
    112 | 113 | 114 | 115 |

    {description}

    116 | 117 |
    118 | ) 119 | } 120 | 121 | export default function SponsoredBills({ pageContext: { consolidatedBills } }) { 122 | return ( 123 | 124 |
    125 | 126 | All Sponsored bills 127 |
    128 | 136 | 137 | Bill 138 | Title 139 | Status 140 | 141 | Summary from{' '} 142 | 143 | Progressive Mass 144 | 145 | 146 | 147 | 148 | } 149 | rowComponent={SponsorshipRow} 150 | /> 151 |
    152 |
    153 |
    154 |
    155 | ) 156 | } 157 | -------------------------------------------------------------------------------- /config/webpackDevServer.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const errorOverlayMiddleware = require('react-error-overlay/middleware'); 4 | const noopServiceWorkerMiddleware = require('react-dev-utils/noopServiceWorkerMiddleware'); 5 | const config = require('./webpack.config.dev'); 6 | const paths = require('./paths'); 7 | 8 | const protocol = process.env.HTTPS === 'true' ? 'https' : 'http'; 9 | const host = process.env.HOST || '0.0.0.0'; 10 | 11 | module.exports = function(proxy, allowedHost) { 12 | return { 13 | // WebpackDevServer 2.4.3 introduced a security fix that prevents remote 14 | // websites from potentially accessing local content through DNS rebinding: 15 | // https://github.com/webpack/webpack-dev-server/issues/887 16 | // https://medium.com/webpack/webpack-dev-server-middleware-security-issues-1489d950874a 17 | // However, it made several existing use cases such as development in cloud 18 | // environment or subdomains in development significantly more complicated: 19 | // https://github.com/facebookincubator/create-react-app/issues/2271 20 | // https://github.com/facebookincubator/create-react-app/issues/2233 21 | // While we're investigating better solutions, for now we will take a 22 | // compromise. Since our WDS configuration only serves files in the `public` 23 | // folder we won't consider accessing them a vulnerability. However, if you 24 | // use the `proxy` feature, it gets more dangerous because it can expose 25 | // remote code execution vulnerabilities in backends like Django and Rails. 26 | // So we will disable the host check normally, but enable it if you have 27 | // specified the `proxy` setting. Finally, we let you override it if you 28 | // really know what you're doing with a special environment variable. 29 | disableHostCheck: !proxy || 30 | process.env.DANGEROUSLY_DISABLE_HOST_CHECK === 'true', 31 | // Enable gzip compression of generated files. 32 | compress: true, 33 | // Silence WebpackDevServer's own logs since they're generally not useful. 34 | // It will still show compile warnings and errors with this setting. 35 | clientLogLevel: 'none', 36 | // By default WebpackDevServer serves physical files from current directory 37 | // in addition to all the virtual build products that it serves from memory. 38 | // This is confusing because those files won’t automatically be available in 39 | // production build folder unless we copy them. However, copying the whole 40 | // project directory is dangerous because we may expose sensitive files. 41 | // Instead, we establish a convention that only files in `public` directory 42 | // get served. Our build script will copy `public` into the `build` folder. 43 | // In `index.html`, you can get URL of `public` folder with %PUBLIC_URL%: 44 | // 45 | // In JavaScript code, you can access it with `process.env.PUBLIC_URL`. 46 | // Note that we only recommend to use `public` folder as an escape hatch 47 | // for files like `favicon.ico`, `manifest.json`, and libraries that are 48 | // for some reason broken when imported through Webpack. If you just want to 49 | // use an image, put it in `src` and `import` it from JavaScript instead. 50 | contentBase: paths.appPublic, 51 | // By default files from `contentBase` will not trigger a page reload. 52 | watchContentBase: true, 53 | // Enable hot reloading server. It will provide /sockjs-node/ endpoint 54 | // for the WebpackDevServer client so it can learn when the files were 55 | // updated. The WebpackDevServer client is included as an entry point 56 | // in the Webpack development configuration. Note that only changes 57 | // to CSS are currently hot reloaded. JS changes will refresh the browser. 58 | hot: true, 59 | // It is important to tell WebpackDevServer to use the same "root" path 60 | // as we specified in the config. In development, we always serve from /. 61 | publicPath: config.output.publicPath, 62 | // WebpackDevServer is noisy by default so we emit custom message instead 63 | // by listening to the compiler events with `compiler.plugin` calls above. 64 | quiet: true, 65 | // Reportedly, this avoids CPU overload on some systems. 66 | // https://github.com/facebookincubator/create-react-app/issues/293 67 | watchOptions: { 68 | ignored: /node_modules/, 69 | }, 70 | // Enable HTTPS if the HTTPS environment variable is set to 'true' 71 | https: protocol === 'https', 72 | host: host, 73 | overlay: false, 74 | historyApiFallback: { 75 | // Paths with dots should still use the history fallback. 76 | // See https://github.com/facebookincubator/create-react-app/issues/387. 77 | disableDotRule: true, 78 | }, 79 | public: allowedHost, 80 | proxy, 81 | setup(app) { 82 | // This lets us open files from the runtime error overlay. 83 | app.use(errorOverlayMiddleware()); 84 | // This service worker file is effectively a 'no-op' that will reset any 85 | // previous service worker registered for the same host:port combination. 86 | // We do this in development to avoid hitting the production cache if 87 | // it used the same host and port. 88 | // https://github.com/facebookincubator/create-react-app/issues/2272#issuecomment-302832432 89 | app.use(noopServiceWorkerMiddleware()); 90 | }, 91 | }; 92 | }; 93 | -------------------------------------------------------------------------------- /src/pages/landing/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import SearchForm from './SearchForm' 3 | import SEO from '../../components/seo' 4 | import Layout from '../../components/layout' 5 | import CardsImage from './images/cards.png' 6 | import FinePrintImage from './images/fine_print.svg' 7 | import LegislatorImage from './images/legislator.svg' 8 | import CollaborationImage from './images/collaboration.svg' 9 | 10 | const LandingPage = () => { 11 | return ( 12 | 13 | 17 |
    18 |
    19 |
    20 |
    21 |
    22 |
    23 |

    Progressive Massachusetts Legislator Scorecard

    24 |
    25 |
    26 | Are your Massachusetts representatives fighting 27 | for your progressive values? 28 |
    29 |
    30 | 31 |
    32 |
    33 | 34 |
    35 |
    36 |
    37 |
    38 |
    39 |
    40 | 41 |
    42 |

    About Progressive Massachusetts

    43 | 44 |
    45 |
    46 | Examples of the Progressive Massachusetts Legislator Report Cards for three legislators 52 |
    53 |
    54 |

    55 | 60 | Progressive Massachusetts 61 | 62 |
    63 | is a grassroots organization that tracks legislation in order to provide people with 64 | the knowledge they need to enact positive local change. 65 |

    66 |
    67 |
    68 |
    69 | 70 |
    71 |
    72 |
    73 |
    74 |

    The Legislator Scorecard can help you:

    75 |
    76 |
    77 |
    78 |
    79 |
    80 |

    81 | A page of small text with magnifying glass over it 87 | Track Legislation 88 |

    89 |

    90 | Progressive Mass provides summaries of important bills and follows their paths 91 | through the State House. 92 |

    93 |
    94 |
    95 |
    96 |
    97 |

    98 | A person reading a book 104 | Learn About Your Reps 105 |

    106 |

    107 | By viewing which legislation your local representatives cosponsored and voted for 108 | or against, you can begin to understand their legislative priorities. 109 |

    110 |
    111 |
    112 |
    113 |
    114 |

    115 | Two people having a conversation 121 | Take Action 122 |

    123 |

    124 | Call or email your local legislators and talk to them about legislation that's 125 | important to you. 126 |

    127 |
    128 |
    129 |
    130 |
    131 |
    132 |
    133 | ) 134 | } 135 | 136 | export default LandingPage 137 | -------------------------------------------------------------------------------- /src/components/legislator/VoteTable.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import LegislatorTable from './LegislatorTable' 4 | 5 | const LegislatorVote = ({ vote, progressivePosition }) => { 6 | if (!progressivePosition) return 'error: progressive position not found' 7 | let badgeClass = 'badge' 8 | 9 | const oppositeDict = (position) => { 10 | if (position.toLowerCase() === 'yes') return 'No' 11 | else if (position.toLowerCase() === 'no') return 'Yes' 12 | else return 'N/A' 13 | } 14 | 15 | if (vote.trim() === '+') { 16 | badgeClass += ' badge-primary' 17 | } else if (vote === '-' || vote === 'NV' || vote === 'NVP') { 18 | badgeClass += ' badge-danger' 19 | } else { 20 | badgeClass += ' badge-clear' 21 | } 22 | 23 | let badgeText = 'N/A' 24 | if (vote.trim() === '+') { 25 | badgeText = 26 | progressivePosition && progressivePosition[0] 27 | ? progressivePosition[0].toUpperCase() + 28 | progressivePosition.slice(1).toLowerCase() 29 | : '' 30 | } else if (vote === '-') { 31 | badgeText = oppositeDict(progressivePosition) 32 | } else if (vote === 'NV') { 33 | badgeText = 'Absent' 34 | } else if (vote === 'NVP') { 35 | badgeText = 'Present' 36 | } 37 | 38 | return {badgeText} 39 | } 40 | 41 | const CumulativeVote = ({ yesVotes, noVotes, progressivePosition }) => { 42 | progressivePosition = progressivePosition || '' 43 | const yesBlock = ( 44 |
    45 | Yes:  46 | 52 | {yesVotes} 53 | 54 |
    55 | ) 56 | 57 | const noBlock = ( 58 |
    59 | No:  60 | 66 | {noVotes} 67 | 68 |
    69 | ) 70 | 71 | if (parseInt(yesVotes) >= parseInt(noVotes)) { 72 | return ( 73 |
    74 | {yesBlock} 75 | {noBlock} 76 |
    77 | ) 78 | } else { 79 | return ( 80 |
    81 | {noBlock} 82 | {yesBlock} 83 |
    84 | ) 85 | } 86 | } 87 | 88 | const VoteRow = ({ 89 | tags, 90 | rowData: { 91 | url, 92 | title, 93 | bill_number, 94 | roll_call_number, 95 | roll_call_url, 96 | yesVotes, 97 | noVotes, 98 | progressive_position: progressivePosition, 99 | description, 100 | yourLegislator, 101 | }, 102 | familyName, 103 | }) => { 104 | return ( 105 | 106 | 107 |
    108 | {bill_number}  109 | 114 | {roll_call_number} 115 | 116 |
    117 |
    {tags}
    118 | 119 | 120 | 125 | {title} 126 |
    127 | 128 | 129 | 130 |

    {description}

    131 |

    132 | Progressive Position:  133 | {progressivePosition} 134 |

    135 | 136 | 137 | 141 | 142 | 143 | 148 | 149 | 150 | ) 151 | } 152 | 153 | const description = ( 154 | <> 155 | Legislators are scored for their roll-called votes on bills and amendments 156 | where an important progressive advancement (or stopping a bad policy) is at 157 | stake.{' '} 158 | 162 | Learn more about the benefits and limitations of a scorecard. 163 | 164 | 165 | ) 166 | 167 | const VoteTable = ({ data: { votes }, familyName }) => { 168 | return ( 169 | 177 | 178 | Bill 179 | Name 180 | 181 | Summary from{' '} 182 | 186 | Progressive Mass 187 | 188 | 189 | {familyName}'s Vote 190 | Vote Tally 191 | 192 | 193 | } 194 | /> 195 | ) 196 | } 197 | 198 | export default VoteTable 199 | 200 | VoteTable.propTypes = { 201 | data: PropTypes.object.isRequired, 202 | legislatorName: PropTypes.string.isRequired, 203 | } 204 | -------------------------------------------------------------------------------- /processData/downloadAndBuildData/updateLegislatorData.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs") 2 | const axios = require("axios") 3 | const JSON5 = require('json5') 4 | require("dotenv").config() 5 | 6 | const query = ` 7 | query getLegislatorInfo($organization: String, $cursor: String) { 8 | people(memberOf: $organization, first: 100, after: $cursor) { 9 | edges { 10 | node { 11 | id 12 | name 13 | givenName 14 | familyName 15 | image 16 | links { 17 | url 18 | } 19 | sources { 20 | url 21 | } 22 | contactDetails { 23 | value 24 | note 25 | type 26 | } 27 | party: currentMemberships(classification: "party") { 28 | organization { 29 | name 30 | } 31 | } 32 | districtUpper: currentMemberships(classification: "upper") { 33 | post { 34 | label 35 | } 36 | } 37 | districtLower: currentMemberships(classification: "lower") { 38 | post { 39 | label 40 | } 41 | } 42 | extras 43 | } 44 | } 45 | pageInfo { 46 | hasNextPage 47 | hasPreviousPage 48 | endCursor 49 | startCursor 50 | } 51 | totalCount 52 | } 53 | } 54 | ` 55 | 56 | const houseId = "ocd-organization/ca38ad9c-c3d5-4c4f-bc2f-d885218ed802" 57 | const senateId = "ocd-organization/1a75ab3a-669b-43fe-ac8d-31a2d6923d9a" 58 | 59 | const makeRequest = ({ organization, cursor }) => { 60 | return axios.post( 61 | "https://openstates.org/graphql", 62 | { 63 | query, 64 | variables: { 65 | organization, 66 | cursor, 67 | }, 68 | }, 69 | { 70 | headers: { 71 | "X-API-KEY": process.env.OPENSTATES_API_KEY, 72 | }, 73 | } 74 | ) 75 | } 76 | 77 | const processData = edges => { 78 | return edges 79 | .map(({ node }) => node) 80 | .map(data => { 81 | try { 82 | data.district = data.districtUpper.length 83 | ? data.districtUpper[0].post.label 84 | : data.districtLower[0].post.label 85 | delete data.districtUpper 86 | delete data.districtLower 87 | try { 88 | data.email = data.contactDetails.filter( 89 | c => c.type === "email" 90 | )[0].value 91 | } catch (e) { 92 | console.warn('No email address for ' + data.name) 93 | data.phone = '' 94 | } 95 | try { 96 | data.phone = data.contactDetails.filter( 97 | c => c.type === "voice" 98 | )[0].value 99 | } catch (e) { 100 | console.warn('No phone number for ' + data.name) 101 | data.phone = '' 102 | } 103 | if (data.links) { 104 | data.url = data.links[0].url 105 | } else { 106 | console.warn(`${data.name} missing links entry, using sources: ${data.sources[0].url}`) 107 | data.url = data.sources[0].url 108 | } 109 | data.party = data.party[0].organization.name 110 | delete data.sources 111 | delete data.contactDetails 112 | if (!data.givenName) { 113 | data.givenName = data.name.split(/\s/)[0] 114 | console.warn( 115 | `${data.name} missing givenName, using ${data.givenName}` 116 | ) 117 | } 118 | if (!data.familyName) { 119 | data.familyName = getFamilyName(data.name) 120 | console.warn( 121 | `${data.name} missing familyName, using ${data.familyName}` 122 | ) 123 | } 124 | data.memberCode = JSON5.parse(data.extras)['member code'] 125 | delete data.extras 126 | if (!data.memberCode) { 127 | console.warn( 128 | `${data.name} missing memberCode inside extras field` 129 | ) 130 | } else if (data.memberCode.length != 3 && data.memberCode.length != 4) { 131 | console.warn( 132 | `${data.name} might have invalid memberCode inside extras field: ${data.memberCode}` 133 | ) 134 | } 135 | if (data.links[0].url.slice(-4) !== data.memberCode && data.links[0].url.slice(-3) !== data.memberCode) { 136 | console.warn(`${data.name} may have wrong URL in links (${data.links[0].url}) compared to memberCode (${data.memberCode}) `) 137 | } 138 | return data 139 | } catch (e) { 140 | console.error('Failed to process data for ' + data.name + ' (' + data.id + ')') 141 | console.error(data) 142 | throw(e) 143 | } 144 | }) 145 | } 146 | 147 | const makePaginatedRequest = ({ organization, cursor, data }) => { 148 | return makeRequest({ organization, cursor }).then( 149 | ({ 150 | data: { 151 | data: { 152 | people: { edges, pageInfo }, 153 | }, 154 | }, 155 | }) => { 156 | ;[].push.apply(data, processData(edges)) 157 | if (pageInfo.hasNextPage) { 158 | return makePaginatedRequest({ 159 | organization, 160 | cursor: pageInfo.endCursor, 161 | data, 162 | }) 163 | } else { 164 | return data 165 | } 166 | } 167 | ) 168 | } 169 | 170 | const requestAllData = organization => { 171 | const data = [] 172 | return makePaginatedRequest({ 173 | organization, 174 | cursor: null, 175 | data, 176 | }) 177 | } 178 | 179 | const getFamilyName = name => { 180 | const splitName = name.split(/\s/) 181 | // last name if there's a final thing like "jr" or "the third" 182 | const possibleLast = splitName.find(item => item.match(/,$/)) 183 | const familyName = possibleLast 184 | ? possibleLast.replace(',', '') 185 | : splitName.slice(-1)[0] 186 | return familyName 187 | } 188 | 189 | module.exports = () => { 190 | Promise.all([requestAllData(houseId), requestAllData(senateId)]).then( 191 | ([houseData, senateData]) => { 192 | fs.writeFileSync( 193 | `${__dirname}/../../src/data/house_legislators.json`, 194 | JSON.stringify(houseData.filter(Boolean), null, 2) 195 | ) 196 | fs.writeFileSync( 197 | `${__dirname}/../../src/data/senate_legislators.json`, 198 | JSON.stringify(senateData.filter(Boolean), null, 2) 199 | ) 200 | } 201 | ) 202 | } 203 | -------------------------------------------------------------------------------- /gatsby-node.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Implement Gatsby's Node APIs in this file. 3 | * 4 | * See: https://www.gatsbyjs.org/docs/node-apis/ 5 | */ 6 | const path = require('path') 7 | const { createOpenGraphImage } = require('gatsby-plugin-open-graph-images') 8 | 9 | const createPageDataStruct = require('./gatsbyNodeHelper/index') 10 | const houseLegislators = require('./src/data/house_legislators.json') 11 | const senateLegislators = require('./src/data/senate_legislators.json') 12 | const legislationData = require('./src/data/legislation.json') 13 | 14 | const { getLegislatorUrlParams, isHouseRep, isSenator } = require('./src/utilities') 15 | 16 | const makePage = ({ chamber, pageData, createPage, legislatorId }) => { 17 | const ogImageFilename = ( 18 | getLegislatorUrlParams(pageData.legislator) + 19 | '-' + 20 | pageData.legislator.district 21 | ) 22 | .toLowerCase() 23 | .replace(/ /g, '-') 24 | .replace(/[.,']/g, '') 25 | // The next two lines remove diacritics, which createOpenGraphImage can't handle 26 | // Source: stackoverflow.com/questions/990904 27 | .normalize('NFD') 28 | .replace(/[\u0300-\u036f]/g, '') 29 | 30 | const context = { 31 | id: legislatorId, 32 | chamber, 33 | pageData, 34 | ogImage: createOpenGraphImage(createPage, { 35 | path: `og-images/legislator/${ogImageFilename}.png`, 36 | component: path.resolve(`src/components/legislator/ogImage.js`), 37 | waitCondition: 'networkidle0', 38 | size: { 39 | width: 630, 40 | height: 315, 41 | }, 42 | context: { 43 | chamber, 44 | pageData, 45 | }, 46 | }), 47 | } 48 | createPage({ 49 | path: `/legislator/${getLegislatorUrlParams(pageData.legislator)}`, 50 | component: require.resolve(`./src/components/legislator/index.js`), 51 | context, 52 | }) 53 | } 54 | 55 | //TODO - other sessions 56 | const sponsorshipsSessionNumber = 194 57 | const votesSessionOrdinal = '193rd' 58 | const sponsorshipsSessionYear = 2025 59 | const votesSessionYear = 2023 60 | 61 | // create individual legislator pages 62 | exports.createPages = async function ({ actions, graphql }) { 63 | const { createPage } = actions 64 | 65 | // sponsorships 66 | const getLegislatorById = (id) => { 67 | const legislator = 68 | houseLegislators.find((legislator) => legislator.id === id) ?? 69 | senateLegislators?.find((legislator) => legislator.id === id) 70 | if (legislator === undefined) { 71 | throw new Error( 72 | `A legislator with ID ${id} is included in the sponsorships table, but that ID was not found among the ` + 73 | `current legislators provided by Open States. Possibly a typo in the ID, or a retired legislator.` 74 | ) 75 | } 76 | const votes = 77 | legislationData[votesSessionYear]?.houseVotes?.find((vote) => vote.id === id) ?? 78 | legislationData[votesSessionYear]?.senateVotes?.find((vote) => vote.id === id) 79 | return { 80 | ...legislator, 81 | ...votes, 82 | } 83 | } 84 | 85 | const sponsoredBills = Object.entries(legislationData[sponsorshipsSessionYear].sponsoredBills) 86 | 87 | const sponsoredBillTemplate = path.resolve(`./src/components/sponsorships/sponsorships.js`) 88 | 89 | const sponsors = legislationData[sponsorshipsSessionYear].sponsorship.map((sponsorshipData) => { 90 | const legislatorData = getLegislatorById(sponsorshipData.id) 91 | return { 92 | sponsorshipData: { ...sponsorshipData.data, ...sponsorshipData.score }, 93 | ...legislatorData, 94 | } 95 | }) 96 | 97 | const billNamesToConsolidatedBillsMap = new Map() 98 | sponsoredBills.forEach((sponsoredBill) => { 99 | const [billNumber, billData] = sponsoredBill 100 | const name = billData.shorthand_title.toLowerCase().trim() 101 | const chamber = Array.from(billNumber)[0] == 'H' ? 'house' : 'senate' 102 | if (billNamesToConsolidatedBillsMap.has(name)) { 103 | // Found a paired bill 104 | const pairedBill = billNamesToConsolidatedBillsMap.get(name) 105 | pairedBill[`${chamber}BillNumber`] = billNumber 106 | pairedBill[`${chamber}Status`] = billData.status 107 | pairedBill[`${chamber}LeadSponsors`] = billData.sponsors.split('&').map(sponsor => sponsor.trim()) 108 | billData['houseBillNumber'] = pairedBill['houseBillNumber'] 109 | billData['senateBillNumber'] = pairedBill['senateBillNumber'] 110 | billData['houseStatus'] = pairedBill['houseStatus'] 111 | billData['senateStatus'] = pairedBill['senateStatus'] 112 | billData['senateLeadSponsors'] = pairedBill['senateLeadSponsors'] 113 | billData['houseLeadSponsors'] = pairedBill['houseLeadSponsors'] 114 | billData.sponsors = billData.senateLeadSponsors.concat(billData.houseLeadSponsors).join(', ') 115 | } else { 116 | // First occurrence of this name 117 | billData[`${chamber}BillNumber`] = billNumber 118 | billData[`${chamber}Status`] = billData.status 119 | billData[`${chamber}LeadSponsors`] = billData.sponsors.split('&').map(sponsor => sponsor.trim()) 120 | billNamesToConsolidatedBillsMap.set(name, billData) 121 | } 122 | }) 123 | 124 | const consolidatedBills = Array.from(billNamesToConsolidatedBillsMap.values()); 125 | 126 | consolidatedBills.forEach((billData) => { 127 | let otherBillNames = '' 128 | const sortedSponsors = sponsors 129 | .filter((sponsor) => { 130 | if (!sponsor.id) return false 131 | for (const bills in sponsor.sponsorshipData) { 132 | if (bills.includes(billData.bill_number) && sponsor.sponsorshipData[bills]) { 133 | otherBillNames = bills 134 | return true 135 | } 136 | } 137 | return false 138 | }) 139 | .map((sponsor) => { 140 | return { 141 | ...sponsor, 142 | name: [sponsor.familyName, sponsor.givenName].join(', '), 143 | } 144 | }) 145 | 146 | const createPageData = { 147 | component: sponsoredBillTemplate, 148 | context: { 149 | billData: { ...billData, otherBillNames }, 150 | sponsors: sortedSponsors, 151 | houseSponsors: sortedSponsors.filter(isHouseRep), 152 | senateSponsors: sortedSponsors.filter(isSenator), 153 | votesSessionOrdinal, 154 | sponsorshipsSessionNumber, 155 | }, 156 | } 157 | if (billData.houseBillNumber !== undefined) { 158 | const createPageHouseBillData = { ...createPageData, path: `/sponsorships/${billData.houseBillNumber}` } 159 | createPage(createPageHouseBillData) 160 | } 161 | if (billData.senateBillNumber !== undefined) { 162 | const createPageSenateBillData = { ...createPageData, path: `/sponsorships/${billData.senateBillNumber}` } 163 | createPage(createPageSenateBillData) 164 | } 165 | }) 166 | 167 | const allSponsoredBillsTemplate = path.resolve(`./src/components/sponsorships/index.js`) 168 | 169 | createPage({ 170 | path: `/sponsorships/all-bills`, 171 | component: allSponsoredBillsTemplate, 172 | context: { 173 | consolidatedBills, 174 | }, 175 | }) 176 | 177 | let legislatorsList = [ 178 | { chamber: 'senate', legislators: senateLegislators }, 179 | { chamber: 'house', legislators: houseLegislators }, 180 | ].map(({ chamber, legislators }) => { 181 | legislators.forEach(({ id: legislatorId }) => { 182 | const pageData = createPageDataStruct({ 183 | chamber, 184 | legislatorId, 185 | }) 186 | makePage({ chamber, pageData, createPage, legislatorId }) 187 | }) 188 | }) 189 | } 190 | -------------------------------------------------------------------------------- /processData/downloadAndBuildData/buildLegislationData.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-extra') 2 | 3 | // because this is inconsistent in the spreadsheets 4 | const tagMap = { 5 | 'shared prosperity': 'economy', 6 | 'all means all': 'justice & equality', 7 | 'good govt/strong democracy': 'government', 8 | 'good government & strong democracy': 'government', 9 | 'strong democracy': 'government', 10 | 'good government/strong democracy': 'government', 11 | 'infrastructure/environment': 'environment', 12 | 'sustainable infrastructure & environmental protection': 'environment', 13 | } 14 | 15 | const finalTags = Object.values(tagMap) 16 | 17 | const normalizeBillNumber = (billNumber) => billNumber.replace(/[.\s]/g, '') 18 | const normalizeTags = (tagString) => { 19 | if (tagString === undefined) { 20 | return [] 21 | } 22 | const tags = tagString 23 | .split(',') 24 | .map((tag) => tag.trim()) 25 | .filter(Boolean) 26 | .map((tag) => tag.toLowerCase()) 27 | .map((tag) => (finalTags.includes(tag) ? tag : tagMap[tag])) 28 | return tags 29 | } 30 | 31 | // handles house, senate, and "sponsored" sheets 32 | const buildLegislationObject = (legislation, key) => { 33 | const processedLegislation = legislation.slice(1).reduce((acc, row) => { 34 | const obj = row.reduce((acc, cell, i) => { 35 | acc[legislation[0][i]] = cell 36 | return acc 37 | }, {}) 38 | obj.bill_number = normalizeBillNumber(obj.bill_number) 39 | obj.tags = normalizeTags(obj.tags) 40 | acc[obj[key]] = obj 41 | return acc 42 | }, {}) 43 | return processedLegislation 44 | } 45 | 46 | const addYesAndNoVotes = (bills, votes) => { 47 | const rollCallRow = votes[2] 48 | rollCallRow.forEach((rollCallNumber, index) => { 49 | if (!rollCallNumber || !rollCallNumber.trim() || rollCallNumber.trim() === 'RC #') return 50 | const billVotes = votes.map((row) => row[index]) 51 | 52 | try { 53 | const progressivePosition = bills[rollCallNumber].progressive_position.toLowerCase() 54 | let yesVotes, noVotes 55 | if (progressivePosition === 'no') { 56 | yesVotes = billVotes.filter((v) => v && v.trim() === '-').length 57 | noVotes = billVotes.filter((v) => v && v.trim() === '+').length 58 | } else { 59 | yesVotes = billVotes.filter((v) => v && v.trim() === '+').length 60 | noVotes = billVotes.filter((v) => v && v.trim() === '-').length 61 | } 62 | 63 | bills[rollCallNumber].noVotes = noVotes 64 | bills[rollCallNumber].yesVotes = yesVotes 65 | } catch (e) { 66 | console.error(`couldnt find bill with roll call # ${rollCallNumber}`) 67 | } 68 | }) 69 | } 70 | 71 | const cleanDescription = (bills) => { 72 | Object.keys(bills).forEach((rollCallNumber) => { 73 | const descriptionLines = bills[rollCallNumber].description.split(/\n/).filter(Boolean) 74 | if (descriptionLines.length === 1) bills[rollCallNumber].description = descriptionLines[0] 75 | else bills[rollCallNumber].description = descriptionLines[1] 76 | }) 77 | } 78 | 79 | const addBillUrls = (bills, session) => { 80 | Object.keys(bills).forEach((rollCallNumber) => { 81 | if (!bills[rollCallNumber].url) { 82 | const url = `https://malegislature.gov/Bills/${session}/${bills[rollCallNumber].bill_number}` 83 | bills[rollCallNumber].url = url 84 | } 85 | }) 86 | } 87 | 88 | const addRollCallUrls = (bills, session, chamber) => { 89 | Object.keys(bills).forEach((rollCallNumber) => { 90 | if (!bills[rollCallNumber].roll_call_url) { 91 | const roll_call_url = `https://malegislature.gov/RollCall/${session}/${chamber}RollCall${rollCallNumber}.pdf` 92 | bills[rollCallNumber].roll_call_url = roll_call_url 93 | } 94 | }) 95 | } 96 | 97 | const buildVoteObject = (votes) => { 98 | const rollCallNumberRow = votes[2] 99 | 100 | // return array instead of object because gatsby's graphql queries 101 | // can only return bulk items in an array 102 | return votes 103 | .map((row) => { 104 | const openStatesLegislatorId = row[0] 105 | if (!openStatesLegislatorId) return 106 | 107 | const votes = row.slice(2).reduce((acc, vote, index) => { 108 | const rollCallNumber = rollCallNumberRow[index + 2] 109 | acc[rollCallNumber] = vote 110 | return acc 111 | }, {}) 112 | 113 | const voteCount = Object.values(votes).filter((vote) => { 114 | return !['n/a', 'nv'].includes(vote.toLowerCase()) 115 | }).length 116 | 117 | const totalScore = Object.entries(votes).reduce((acc, [, vote]) => { 118 | if (vote.trim() === '+') { 119 | return acc + 1 120 | } 121 | return acc 122 | }, 0) 123 | 124 | const percentageScore = Math.round((totalScore / voteCount) * 100) 125 | 126 | return { 127 | id: openStatesLegislatorId, 128 | data: votes, 129 | score: percentageScore, 130 | recordedVotePercentage: Math.round((voteCount / Object.keys(votes).length) * 100), 131 | } 132 | }) 133 | .filter(Boolean) 134 | } 135 | 136 | const buildSponsorshipObject = (sponsorship) => { 137 | const billNumbers = sponsorship.map((s) => s[1]).slice(2) 138 | const openStateIds = sponsorship[0] 139 | return openStateIds 140 | .map((openStatesId, legislatorIndex) => { 141 | if (!openStatesId) return 142 | const data = billNumbers.reduce((acc, billNo, i) => { 143 | // for easier matching in gatsby-node.js 144 | billNo = billNo.replace(/\./g, '') 145 | let letterVote = sponsorship[i + 2][legislatorIndex] 146 | acc[billNo] = letterVote.toUpperCase() === 'Y' 147 | return acc 148 | }, {}) 149 | const score = parseInt( 150 | Object.values(data) 151 | .reduce((acc, curr) => acc + curr, 0) 152 | .toFixed() 153 | ) 154 | 155 | return { 156 | id: openStatesId, 157 | data, 158 | score, 159 | } 160 | }) 161 | .filter(Boolean) 162 | } 163 | 164 | const sessionDict = { 165 | 2017: 190, 166 | 2019: 191, 167 | 2021: 192, 168 | 2023: 193, 169 | 2025: 194, 170 | } 171 | 172 | const buildLegislationDataForYear = (year) => { 173 | const data = JSON.parse(fs.readFileSync(`${__dirname}/tmp/${year}.json`, 'utf8')) 174 | 175 | ;['sponsored', 'house', 'senate'].forEach((type) => { 176 | const key = type === 'sponsored' ? 'bill_number' : 'roll_call_number' 177 | data[`${type}Bills`] = buildLegislationObject(data[`${type}Bills`], key) 178 | }) 179 | 180 | // add additional vote data to bills 181 | // modifies in-place 182 | ;['house', 'senate'].forEach((type) => { 183 | if (!data[`${type}Votes`] || !Object.keys(data[`${type}Bills`]).length) return 184 | addYesAndNoVotes(data[`${type}Bills`], data[`${type}Votes`]) 185 | }) 186 | ;['house', 'senate'].forEach((type) => { 187 | cleanDescription(data[`${type}Bills`]) 188 | addBillUrls(data[`${type}Bills`], sessionDict[year]) 189 | addRollCallUrls(data[`${type}Bills`], sessionDict[year], type) 190 | }) 191 | 192 | if (data.sponsorship.length) data.sponsorship = buildSponsorshipObject(data.sponsorship) 193 | 194 | if (data.houseVotes) data.houseVotes = buildVoteObject(data.houseVotes, data.houseBills) 195 | if (data.senateVotes) data.senateVotes = buildVoteObject(data.senateVotes, data.senateBills) 196 | 197 | return data 198 | } 199 | 200 | module.exports = () => { 201 | return { 202 | 2017: buildLegislationDataForYear(2017), 203 | 2019: buildLegislationDataForYear(2019), 204 | 2021: buildLegislationDataForYear(2021), 205 | 2023: buildLegislationDataForYear(2023), 206 | 2025: buildLegislationDataForYear(2025), 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/pages/all-legislators/LegislatorList.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import LazyLoad, { forceCheck } from 'react-lazyload' 4 | import { navigate } from 'gatsby' 5 | import SortButton from './SortButton' 6 | import ProgressBar from '../../components/progressBar' 7 | import InfoPopover from '../../components/InfoPopover' 8 | import defaultPhoto from '../../images/default-photo.jpg' 9 | import { QUERIES, getLegislatorUrlParams } from '../../utilities' 10 | import styled from 'styled-components' 11 | 12 | const StyledRow = styled.tr` 13 | @media ${QUERIES.phoneAndSmaller} { 14 | display: grid; 15 | grid-template-areas: 'name party' 'score score'; 16 | grid-template-columns: 1fr min-content; 17 | grid-template-rows: 1fr min-content; 18 | 19 | & > td:first-child { 20 | grid-area: name; 21 | border-bottom: none; 22 | } 23 | 24 | & > td[data-label='Party'] { 25 | grid-area: party; 26 | border-bottom: none; 27 | display: flex; 28 | flex-direction: column; 29 | text-align: center; 30 | } 31 | 32 | & > td[data-label='Party'] > span { 33 | height: 100%; 34 | display: flex; 35 | flex-direction: column; 36 | justify-content: center; 37 | } 38 | 39 | & > td[data-label*='Progressive Rating'] { 40 | grid-area: score; 41 | border-top: none; 42 | align-self: self-end; 43 | } 44 | 45 | & > td[data-label*='Progressive Rating'] > div { 46 | max-width: revert !important; 47 | } 48 | } 49 | ` 50 | 51 | const LegislatorRow = ({ legislator, i, chamber, sessionNumber }) => { 52 | return ( 53 | { 57 | e.preventDefault() 58 | navigate(`/legislator/${getLegislatorUrlParams(legislator)}`) 59 | }}> 60 | 61 | 62 | 63 | 64 | {'Photo { 69 | if (e.target.src !== window.location.origin + defaultPhoto) { 70 | e.target.src = defaultPhoto 71 | } 72 | }} 73 | /> 74 | 75 | {legislator.name}  76 | {legislator.specialElectionUrl ? ( 77 | special election pending to elect a replacement.`} 83 | /> 84 | ) : null} 85 | 86 | 87 | 88 | 89 | {legislator.party.slice(0, 1)} 90 | 91 | 92 |
    93 | 94 |
    95 | 96 |
    97 | ) 98 | } 99 | 100 | const LegislatorList = (props) => { 101 | const [sort, setSort] = React.useState(['name', 'desc']) 102 | const [filter, setFilter] = React.useState('') 103 | 104 | const sortData = (data) => { 105 | const normalizeSortVal = (val) => { 106 | if (!val) { 107 | return 0 108 | } else if (typeof val === 'string') { 109 | return val.toLowerCase() 110 | } else { 111 | return val 112 | } 113 | } 114 | 115 | const normalizeRatingVal = (val, data) => { 116 | if (data.recordedVotePercentage < 90) { 117 | return 0 118 | } else { 119 | return val 120 | } 121 | } 122 | 123 | const sortKey = sort[0] 124 | const order = sort[1] 125 | return data.sort((a, b) => { 126 | let aSort = normalizeSortVal(a[sortKey]) 127 | let bSort = normalizeSortVal(b[sortKey]) 128 | 129 | if (sortKey === 'score') { 130 | // so that people who didnt vote much don't get sorted to the top 131 | aSort = normalizeRatingVal(aSort, a) 132 | bSort = normalizeRatingVal(bSort, b) 133 | } 134 | 135 | if (aSort < bSort) { 136 | return order === 'asc' ? 1 : -1 137 | } else if (aSort > bSort) { 138 | return order === 'asc' ? -1 : 1 139 | } else { 140 | // find an appropriate secondary sort 141 | if (sortKey === 'score') { 142 | if (normalizeSortVal(a.name) < normalizeSortVal(b.name)) return -1 143 | else if (normalizeSortVal(a.name) > normalizeSortVal(b.name)) return 1 144 | // this will never happen...right... 145 | else { 146 | return 0 147 | } // eslint-disable-line brace-style 148 | } else { 149 | return normalizeRatingVal(b.score, b) - normalizeRatingVal(a.score, a) 150 | } 151 | } 152 | }) 153 | } 154 | 155 | const handleSort = (currentSort) => { 156 | if (sort[0] === currentSort) { 157 | // just switch between asc and desc 158 | const order = sort[1] === 'asc' ? 'desc' : 'asc' 159 | setSort([currentSort, order]) 160 | } else { 161 | setSort([currentSort, 'desc']) 162 | } 163 | setTimeout(forceCheck, 1) 164 | } 165 | 166 | const filterData = (rows = []) => { 167 | const filterRegex = new RegExp('^' + filter.toLowerCase()) 168 | return rows.filter((r) => { 169 | const names = r.name.split(',') 170 | return ( 171 | names.filter((n) => { 172 | return n.trim().toLowerCase().match(filterRegex) 173 | }).length > 0 174 | ) 175 | }) 176 | } 177 | 178 | const data = sortData(filterData(props.data)) 179 | 180 | return ( 181 |
    182 |
    183 |
    184 | 190 | { 196 | setFilter(e.target.value) 197 | setTimeout(forceCheck, 1) 198 | }} 199 | /> 200 |
    201 | 202 | 203 | 204 | 205 | 208 | 211 | 219 | 220 | 221 | 222 | {data.map((legislator, i) => ( 223 | 230 | ))} 231 | 232 |
    206 | 207 | 209 | 210 | 212 | 218 |
    233 |
    234 |
    235 | ) 236 | } 237 | 238 | LegislatorList.propTypes = { 239 | data: PropTypes.array.isRequired, 240 | chamber: PropTypes.string.isRequired, 241 | sessionNumber: PropTypes.string.isRequired, 242 | } 243 | 244 | export default LegislatorList 245 | -------------------------------------------------------------------------------- /src/data/senate_legislators.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "ocd-person/94929da6-2219-4a44-99c3-d6561eead9f5", 4 | "name": "Will Brownsberger", 5 | "givenName": "Will", 6 | "familyName": "Brownsberger", 7 | "image": "https://malegislature.gov/Legislators/Profile/170/WNB0.jpg", 8 | "links": [ 9 | { 10 | "url": "https://malegislature.gov/Legislators/Profile/WNB0" 11 | }, 12 | { 13 | "url": "https://willbrownsberger.com/" 14 | } 15 | ], 16 | "party": "Democratic", 17 | "district": "Suffolk and Middlesex", 18 | "email": "william.brownsberger@masenate.gov", 19 | "phone": "617-722-1280", 20 | "url": "https://malegislature.gov/Legislators/Profile/WNB0", 21 | "memberCode": "WNB0" 22 | }, 23 | { 24 | "id": "ocd-person/d0956cb2-93df-4096-82ce-d39fbf0b9e36", 25 | "name": "Joan Lovely", 26 | "givenName": "Joan", 27 | "familyName": "Lovely", 28 | "image": "https://malegislature.gov/Legislators/Profile/170/JBL0.jpg", 29 | "links": [ 30 | { 31 | "url": "https://malegislature.gov/Legislators/Profile/JBL0" 32 | }, 33 | { 34 | "url": "https://www.senatorjoanlovely.com/" 35 | } 36 | ], 37 | "party": "Democratic", 38 | "district": "Second Essex", 39 | "email": "joan.lovely@masenate.gov", 40 | "phone": "617-722-1410", 41 | "url": "https://malegislature.gov/Legislators/Profile/JBL0", 42 | "memberCode": "JBL0" 43 | }, 44 | { 45 | "id": "ocd-person/5a0688ef-c0cd-469d-bd3c-fe9848724387", 46 | "name": "Julian Cyr", 47 | "givenName": "Julian", 48 | "familyName": "Cyr", 49 | "image": "https://malegislature.gov/Legislators/Profile/170/JAC0.jpg", 50 | "links": [ 51 | { 52 | "url": "https://malegislature.gov/Legislators/Profile/JAC0" 53 | }, 54 | { 55 | "url": "https://www.senatorcyr.com/" 56 | } 57 | ], 58 | "party": "Democratic", 59 | "district": "Cape and Islands", 60 | "email": "julian.cyr@masenate.gov", 61 | "phone": "617-722-1570", 62 | "url": "https://malegislature.gov/Legislators/Profile/JAC0", 63 | "memberCode": "JAC0" 64 | }, 65 | { 66 | "id": "ocd-person/d691b834-a68d-44da-8140-9582ee37d8a6", 67 | "name": "Sal DiDomenico", 68 | "givenName": "Sal", 69 | "familyName": "DiDomenico", 70 | "image": "https://malegislature.gov/Legislators/Profile/170/SND0.jpg", 71 | "links": [ 72 | { 73 | "url": "https://malegislature.gov/Legislators/Profile/SND0" 74 | }, 75 | { 76 | "url": "https://www.senatordidomenico.com/" 77 | } 78 | ], 79 | "party": "Democratic", 80 | "district": "Middlesex and Suffolk", 81 | "email": "sal.didomenico@masenate.gov", 82 | "phone": "617-722-1650", 83 | "url": "https://malegislature.gov/Legislators/Profile/SND0", 84 | "memberCode": "SND0" 85 | }, 86 | { 87 | "id": "ocd-person/2cf31d35-c3e8-4627-a534-08dce38078db", 88 | "name": "Mike Barrett", 89 | "givenName": "Mike", 90 | "familyName": "Barrett", 91 | "image": "https://malegislature.gov/Legislators/Profile/170/MJB0.jpg", 92 | "links": [ 93 | { 94 | "url": "https://malegislature.gov/Legislators/Profile/MJB0" 95 | }, 96 | { 97 | "url": "https://senatormikebarrett.com/" 98 | } 99 | ], 100 | "party": "Democratic", 101 | "district": "Third Middlesex", 102 | "email": "mike.barrett@masenate.gov", 103 | "phone": "617-722-1572", 104 | "url": "https://malegislature.gov/Legislators/Profile/MJB0", 105 | "memberCode": "MJB0" 106 | }, 107 | { 108 | "id": "ocd-person/8ca6ea6c-1121-40b3-a5cf-9b55dfe6a2b0", 109 | "name": "Nick Collins", 110 | "givenName": "Nick", 111 | "familyName": "Collins", 112 | "image": "https://malegislature.gov/Legislators/Profile/170/N_C0.jpg", 113 | "links": [ 114 | { 115 | "url": "https://malegislature.gov/Legislators/Profile/N_C0" 116 | } 117 | ], 118 | "party": "Democratic", 119 | "district": "First Suffolk", 120 | "email": "nick.collins@masenate.gov", 121 | "phone": "617-722-1150", 122 | "url": "https://malegislature.gov/Legislators/Profile/N_C0", 123 | "memberCode": "N_C0" 124 | }, 125 | { 126 | "id": "ocd-person/edebef62-78d9-4f8b-a94d-65a29dbc09f6", 127 | "name": "Ryan Fattman", 128 | "givenName": "Ryan", 129 | "familyName": "Fattman", 130 | "image": "https://malegislature.gov/Legislators/Profile/170/RCF0.jpg", 131 | "links": [ 132 | { 133 | "url": "https://malegislature.gov/Legislators/Profile/RCF0" 134 | } 135 | ], 136 | "party": "Republican", 137 | "district": "Worcester and Hampden", 138 | "email": "ryan.fattman@masenate.gov", 139 | "phone": "617-722-1420", 140 | "url": "https://malegislature.gov/Legislators/Profile/RCF0", 141 | "memberCode": "RCF0" 142 | }, 143 | { 144 | "id": "ocd-person/ece82669-b6f6-4f0f-ae5d-7b238b1cd1dd", 145 | "name": "Cynthia Creem", 146 | "givenName": "Cynthia", 147 | "familyName": "Creem", 148 | "image": "https://malegislature.gov/Legislators/Profile/170/CSC0.jpg", 149 | "links": [ 150 | { 151 | "url": "https://malegislature.gov/Legislators/Profile/CSC0" 152 | }, 153 | { 154 | "url": "https://www.senatorcindycreem.com/" 155 | } 156 | ], 157 | "party": "Democratic", 158 | "district": "Norfolk and Middlesex", 159 | "email": "cynthia.creem@masenate.gov", 160 | "phone": "617-722-1639", 161 | "url": "https://malegislature.gov/Legislators/Profile/CSC0", 162 | "memberCode": "CSC0" 163 | }, 164 | { 165 | "id": "ocd-person/cd05a88c-19f0-4fc5-bc3b-d339572842b7", 166 | "name": "Dylan Fernandes", 167 | "givenName": "Dylan", 168 | "familyName": "Fernandes", 169 | "image": "https://malegislature.gov/Legislators/Profile/170/DAF0.jpg", 170 | "links": [ 171 | { 172 | "url": "https://malegislature.gov/Legislators/Profile/DAF1" 173 | }, 174 | { 175 | "url": "https://malegislature.gov/Legislators/Profile/DAF0" 176 | } 177 | ], 178 | "party": "Democratic", 179 | "district": "Plymouth and Barnstable", 180 | "email": "dylan.fernandes@masenate.gov", 181 | "phone": "617-722-1330", 182 | "url": "https://malegislature.gov/Legislators/Profile/DAF1", 183 | "memberCode": "DAF0" 184 | }, 185 | { 186 | "id": "ocd-person/7b48bd48-8d27-4dd8-958d-703571efe150", 187 | "name": "Mike Moore", 188 | "givenName": "Mike", 189 | "familyName": "Moore", 190 | "image": "https://malegislature.gov/Legislators/Profile/170/MOM0.jpg", 191 | "links": [ 192 | { 193 | "url": "https://malegislature.gov/Legislators/Profile/MOM0" 194 | }, 195 | { 196 | "url": "https://www.senatormikemoore.com/" 197 | } 198 | ], 199 | "party": "Democratic", 200 | "district": "Second Worcester", 201 | "email": "michael.moore@masenate.gov", 202 | "phone": "617-722-1485", 203 | "url": "https://malegislature.gov/Legislators/Profile/MOM0", 204 | "memberCode": "MOM0" 205 | }, 206 | { 207 | "id": "ocd-person/6de50ed2-4fe8-4b98-894d-0bc391c876b5", 208 | "name": "Pat Jehlen", 209 | "givenName": "Pat", 210 | "familyName": "Jehlen", 211 | "image": "https://malegislature.gov/Legislators/Profile/170/PDJ0.jpg", 212 | "links": [ 213 | { 214 | "url": "https://malegislature.gov/Legislators/Profile/PDJ0" 215 | }, 216 | { 217 | "url": "https://www.patjehlen.org/" 218 | } 219 | ], 220 | "party": "Democratic", 221 | "district": "Second Middlesex", 222 | "email": "patricia.jehlen@masenate.gov", 223 | "phone": "617-722-1578", 224 | "url": "https://malegislature.gov/Legislators/Profile/PDJ0", 225 | "memberCode": "PDJ0" 226 | }, 227 | { 228 | "id": "ocd-person/1b7d1710-da71-46cd-9ee3-fc891556f3a9", 229 | "name": "Bill Driscoll", 230 | "givenName": "Bill", 231 | "familyName": "Driscoll", 232 | "image": "https://malegislature.gov/Legislators/Profile/170/WJD1.jpg", 233 | "links": [ 234 | { 235 | "url": "https://malegislature.gov/Legislators/Profile/WJD1" 236 | }, 237 | { 238 | "url": "https://www.billdriscolljr.com/" 239 | }, 240 | { 241 | "url": "https://malegislature.gov/Legislators/Profile/WJD0" 242 | } 243 | ], 244 | "party": "Democratic", 245 | "district": "Norfolk, Plymouth and Bristol", 246 | "email": "william.driscoll@masenate.gov", 247 | "phone": "617-722-1643", 248 | "url": "https://malegislature.gov/Legislators/Profile/WJD1", 249 | "memberCode": "WJD1" 250 | }, 251 | { 252 | "id": "ocd-person/77994b2c-f88c-4a5f-bf9d-7abd1875d8dc", 253 | "name": "Brendan Crighton", 254 | "givenName": "Brendan", 255 | "familyName": "Crighton", 256 | "image": "https://malegislature.gov/Legislators/Profile/170/BPC0.jpg", 257 | "links": [ 258 | { 259 | "url": "https://malegislature.gov/Legislators/Profile/BPC0" 260 | } 261 | ], 262 | "party": "Democratic", 263 | "district": "Third Essex", 264 | "email": "brendan.crighton@masenate.gov", 265 | "phone": "617-722-1350", 266 | "url": "https://malegislature.gov/Legislators/Profile/BPC0", 267 | "memberCode": "BPC0" 268 | }, 269 | { 270 | "id": "ocd-person/92a770ae-a348-4f86-92ad-c4a8ebc568f6", 271 | "name": "Jason Lewis", 272 | "givenName": "Jason", 273 | "familyName": "Lewis", 274 | "image": "https://malegislature.gov/Legislators/Profile/170/jml0.jpg", 275 | "links": [ 276 | { 277 | "url": "https://malegislature.gov/Legislators/Profile/jml0" 278 | }, 279 | { 280 | "url": "https://malegislature.gov/Legislators/Profile/JML0" 281 | }, 282 | { 283 | "url": "https://senatorjasonlewis.com/" 284 | } 285 | ], 286 | "party": "Democratic", 287 | "district": "Fifth Middlesex", 288 | "email": "jason.lewis@masenate.gov", 289 | "phone": "617-722-1206", 290 | "url": "https://malegislature.gov/Legislators/Profile/jml0", 291 | "memberCode": "jml0" 292 | }, 293 | { 294 | "id": "ocd-person/493988da-4de8-49a0-8d6c-090097e89920", 295 | "name": "Patrick O'Connor", 296 | "givenName": "Patrick", 297 | "familyName": "O'Connor", 298 | "image": "https://malegislature.gov/Legislators/Profile/170/PMO.jpg", 299 | "links": [ 300 | { 301 | "url": "https://malegislature.gov/Legislators/Profile/PMO" 302 | } 303 | ], 304 | "party": "Republican", 305 | "district": "First Plymouth and Norfolk", 306 | "email": "patrick.oconnor@masenate.gov", 307 | "phone": "617-722-1646", 308 | "url": "https://malegislature.gov/Legislators/Profile/PMO", 309 | "memberCode": "PMO" 310 | }, 311 | { 312 | "id": "ocd-person/a37a3f66-83a2-4a7e-90e3-8de41be7262d", 313 | "name": "John Velis", 314 | "givenName": "John", 315 | "familyName": "Velis", 316 | "image": "https://malegislature.gov/Legislators/Profile/170/jcv1.jpg", 317 | "links": [ 318 | { 319 | "url": "https://malegislature.gov/Legislators/Profile/jcv1" 320 | }, 321 | { 322 | "url": "https://malegislature.gov/Legislators/Profile/JCV0" 323 | } 324 | ], 325 | "party": "Democratic", 326 | "district": "Hampden and Hampshire", 327 | "email": "john.velis@masenate.gov", 328 | "phone": "617-722-1415", 329 | "url": "https://malegislature.gov/Legislators/Profile/jcv1", 330 | "memberCode": "JCV0" 331 | }, 332 | { 333 | "id": "ocd-person/456b0061-febd-4ff7-8547-42be0685526f", 334 | "name": "Cindy Friedman", 335 | "givenName": "Cindy", 336 | "familyName": "Friedman", 337 | "image": "https://malegislature.gov/Legislators/Profile/170/CFF0.jpg", 338 | "links": [ 339 | { 340 | "url": "https://malegislature.gov/Legislators/Profile/CFF0" 341 | }, 342 | { 343 | "url": "https://cindyfriedman.org/" 344 | } 345 | ], 346 | "party": "Democratic", 347 | "district": "Fourth Middlesex", 348 | "email": "cindy.friedman@masenate.gov", 349 | "phone": "617-722-1432", 350 | "url": "https://malegislature.gov/Legislators/Profile/CFF0", 351 | "memberCode": "CFF0" 352 | }, 353 | { 354 | "id": "ocd-person/31600b44-797b-4baf-80aa-0762b4f0aff5", 355 | "name": "Mark Montigny", 356 | "givenName": "Mark", 357 | "familyName": "Montigny", 358 | "image": "https://malegislature.gov/Legislators/Profile/170/MCM0.jpg", 359 | "links": [ 360 | { 361 | "url": "https://malegislature.gov/Legislators/Profile/MCM0" 362 | } 363 | ], 364 | "party": "Democratic", 365 | "district": "Second Bristol and Plymouth", 366 | "email": "mark.montigny@masenate.gov", 367 | "phone": "617-722-1440", 368 | "url": "https://malegislature.gov/Legislators/Profile/MCM0", 369 | "memberCode": "MCM0" 370 | }, 371 | { 372 | "id": "ocd-person/6a82d675-3496-48af-88cf-23c26355b01f", 373 | "name": "Mike Rush", 374 | "givenName": "Mike", 375 | "familyName": "Rush", 376 | "image": "https://malegislature.gov/Legislators/Profile/170/MFR0.jpg", 377 | "links": [ 378 | { 379 | "url": "https://malegislature.gov/Legislators/Profile/MFR0" 380 | } 381 | ], 382 | "party": "Democratic", 383 | "district": "Norfolk and Suffolk", 384 | "email": "mike.rush@masenate.gov", 385 | "phone": "617-722-1348", 386 | "url": "https://malegislature.gov/Legislators/Profile/MFR0", 387 | "memberCode": "MFR0" 388 | }, 389 | { 390 | "id": "ocd-person/b390af07-1fa2-4516-b8f0-b6bd1b62121a", 391 | "name": "Paul Feeney", 392 | "givenName": "Paul", 393 | "familyName": "Feeney", 394 | "image": "https://malegislature.gov/Legislators/Profile/170/PRF0.jpg", 395 | "links": [ 396 | { 397 | "url": "https://malegislature.gov/Legislators/Profile/PRF0" 398 | } 399 | ], 400 | "party": "Democratic", 401 | "district": "Bristol and Norfolk", 402 | "email": "paul.feeney@masenate.gov", 403 | "phone": "617-722-1222", 404 | "url": "https://malegislature.gov/Legislators/Profile/PRF0", 405 | "memberCode": "PRF0" 406 | }, 407 | { 408 | "id": "ocd-person/c3cdd26f-1314-4366-89f6-17c4c540bce9", 409 | "name": "Mike Brady", 410 | "givenName": "Mike", 411 | "familyName": "Brady", 412 | "image": "https://malegislature.gov/Legislators/Profile/170/MDB0.jpg", 413 | "links": [ 414 | { 415 | "url": "https://malegislature.gov/Legislators/Profile/MDB0" 416 | } 417 | ], 418 | "party": "Democratic", 419 | "district": "Second Plymouth and Norfolk", 420 | "email": "michael.brady@masenate.gov", 421 | "phone": "617-722-1200", 422 | "url": "https://malegislature.gov/Legislators/Profile/MDB0", 423 | "memberCode": "MDB0" 424 | }, 425 | { 426 | "id": "ocd-person/7be04e69-85c6-44e4-a933-88c59c0675a5", 427 | "name": "John Keenan", 428 | "givenName": "John", 429 | "familyName": "Keenan", 430 | "image": "https://malegislature.gov/Legislators/Profile/170/JFK0.jpg", 431 | "links": [ 432 | { 433 | "url": "https://malegislature.gov/Legislators/Profile/JFK0" 434 | }, 435 | { 436 | "url": "https://senatorjohnkeenan.com/" 437 | } 438 | ], 439 | "party": "Democratic", 440 | "district": "Norfolk and Plymouth", 441 | "email": "john.keenan@masenate.gov", 442 | "phone": "617-722-1494", 443 | "url": "https://malegislature.gov/Legislators/Profile/JFK0", 444 | "memberCode": "JFK0" 445 | }, 446 | { 447 | "id": "ocd-person/fd5aaac8-48f5-42c2-82a8-abaf6c5de650", 448 | "name": "Paul Mark", 449 | "givenName": "Paul", 450 | "familyName": "Mark", 451 | "image": "https://malegislature.gov/Legislators/Profile/170/PWM0.jpg", 452 | "links": [ 453 | { 454 | "url": "https://malegislature.gov/Legislators/Profile/PWM1" 455 | }, 456 | { 457 | "url": "https://malegislature.gov/Legislators/Profile/PWM0" 458 | } 459 | ], 460 | "party": "Democratic", 461 | "district": "Berkshire, Hampden, Franklin and Hampshire", 462 | "email": "paul.mark@masenate.gov", 463 | "phone": "413-464-5635", 464 | "url": "https://malegislature.gov/Legislators/Profile/PWM1", 465 | "memberCode": "PWM0" 466 | }, 467 | { 468 | "id": "ocd-person/af0d6647-3a7b-48d0-971c-bde55a75e78c", 469 | "name": "Michael Rodrigues", 470 | "givenName": "Michael", 471 | "familyName": "Rodrigues", 472 | "image": "https://malegislature.gov/Legislators/Profile/170/MJR0.jpg", 473 | "links": [ 474 | { 475 | "url": "https://malegislature.gov/Legislators/Profile/MJR0" 476 | }, 477 | { 478 | "url": "https://www.senatorrodrigues.com/" 479 | } 480 | ], 481 | "party": "Democratic", 482 | "district": "First Bristol and Plymouth", 483 | "email": "michael.rodrigues@masenate.gov", 484 | "phone": "617-722-1114", 485 | "url": "https://malegislature.gov/Legislators/Profile/MJR0", 486 | "memberCode": "MJR0" 487 | }, 488 | { 489 | "id": "ocd-person/429f87cf-f7a1-4201-ade4-9efeb9149d03", 490 | "name": "Bruce Tarr", 491 | "givenName": "Bruce", 492 | "familyName": "Tarr", 493 | "image": "https://malegislature.gov/Legislators/Profile/170/BET0.jpg", 494 | "links": [ 495 | { 496 | "url": "https://malegislature.gov/Legislators/Profile/BET0" 497 | }, 498 | { 499 | "url": "https://www.tarrtalk.com/" 500 | } 501 | ], 502 | "party": "Republican", 503 | "district": "First Essex and Middlesex", 504 | "email": "bruce.tarr@masenate.gov", 505 | "phone": "617-722-1600", 506 | "url": "https://malegislature.gov/Legislators/Profile/BET0", 507 | "memberCode": "BET0" 508 | }, 509 | { 510 | "id": "ocd-person/e48879ef-ed74-4f93-8e5b-c0f3752d4fd7", 511 | "name": "Karen Spilka", 512 | "givenName": "Karen", 513 | "familyName": "Spilka", 514 | "image": "https://malegislature.gov/Legislators/Profile/170/KES0.jpg", 515 | "links": [ 516 | { 517 | "url": "https://malegislature.gov/Legislators/Profile/KES0" 518 | }, 519 | { 520 | "url": "https://karenspilka.com/" 521 | } 522 | ], 523 | "party": "Democratic", 524 | "district": "Middlesex and Norfolk", 525 | "email": "karen.spilka@masenate.gov", 526 | "phone": "617-722-1500", 527 | "url": "https://malegislature.gov/Legislators/Profile/KES0", 528 | "memberCode": "KES0" 529 | }, 530 | { 531 | "id": "ocd-person/23704722-a50c-4148-8f14-98c7d78324b9", 532 | "name": "Jamie Eldridge", 533 | "givenName": "Jamie", 534 | "familyName": "Eldridge", 535 | "image": "https://malegislature.gov/Legislators/Profile/170/JBE0.jpg", 536 | "links": [ 537 | { 538 | "url": "https://malegislature.gov/Legislators/Profile/JBE0" 539 | }, 540 | { 541 | "url": "https://www.senatoreldridge.com/" 542 | } 543 | ], 544 | "party": "Democratic", 545 | "district": "Middlesex and Worcester", 546 | "email": "james.eldridge@masenate.gov", 547 | "phone": "617-722-1120", 548 | "url": "https://malegislature.gov/Legislators/Profile/JBE0", 549 | "memberCode": "JBE0" 550 | }, 551 | { 552 | "id": "ocd-person/fb97b139-c0dd-41b5-ae52-159528f47b11", 553 | "name": "Becca Rausch", 554 | "givenName": "Becca", 555 | "familyName": "Rausch", 556 | "image": "https://www.brandeis.edu/now/2019/january/images/rausch620.jpg", 557 | "links": [ 558 | { 559 | "url": "https://malegislature.gov/Legislators/Profile/RLR0" 560 | }, 561 | { 562 | "url": "https://www.beccarauschma.com/" 563 | } 564 | ], 565 | "party": "Democratic", 566 | "district": "Norfolk, Worcester and Middlesex", 567 | "email": "becca.rausch@masenate.gov", 568 | "phone": "617-722-1555", 569 | "url": "https://malegislature.gov/Legislators/Profile/RLR0", 570 | "memberCode": "RLR0" 571 | }, 572 | { 573 | "id": "ocd-person/2998b05b-b83e-4a9b-9659-a609ddc63a34", 574 | "name": "Jo Comerford", 575 | "givenName": "Jo", 576 | "familyName": "Comerford", 577 | "image": "https://malegislature.gov/Legislators/Profile/170/JMC0.jpg", 578 | "links": [ 579 | { 580 | "url": "https://malegislature.gov/Legislators/Profile/JMC0" 581 | }, 582 | { 583 | "url": "https://senatorjocomerford.org/" 584 | } 585 | ], 586 | "party": "Democratic", 587 | "district": "Hampshire, Franklin and Worcester", 588 | "email": "jo.comerford@masenate.gov", 589 | "phone": "617-722-1532", 590 | "url": "https://malegislature.gov/Legislators/Profile/JMC0", 591 | "memberCode": "JMC0" 592 | }, 593 | { 594 | "id": "ocd-person/49397268-3fc9-4c83-a385-f55be18597f4", 595 | "name": "Barry Finegold", 596 | "givenName": "Barry", 597 | "familyName": "Finegold", 598 | "image": "https://malegislature.gov/Legislators/Profile/170/BRF0.jpg", 599 | "links": [ 600 | { 601 | "url": "https://malegislature.gov/Legislators/Profile/BRF0" 602 | } 603 | ], 604 | "party": "Democratic", 605 | "district": "Second Essex and Middlesex", 606 | "email": "barry.finegold@masenate.gov", 607 | "phone": "617-722-1612", 608 | "url": "https://malegislature.gov/Legislators/Profile/BRF0", 609 | "memberCode": "BRF0" 610 | }, 611 | { 612 | "id": "ocd-person/6ad38b3e-46dc-4367-b85a-26793ea9ff65", 613 | "name": "Ed Kennedy", 614 | "givenName": "Ed", 615 | "familyName": "Kennedy", 616 | "image": "https://upload.wikimedia.org/wikipedia/commons/1/1f/Edward_J_Kennedy_MA_government_photo.jpg", 617 | "links": [ 618 | { 619 | "url": "https://malegislature.gov/Legislators/Profile/EDJ0" 620 | }, 621 | { 622 | "url": "https://senatoredkennedy.com/" 623 | } 624 | ], 625 | "party": "Democratic", 626 | "district": "First Middlesex", 627 | "email": "edward.kennedy@masenate.gov", 628 | "phone": "617-722-1630", 629 | "url": "https://malegislature.gov/Legislators/Profile/EDJ0", 630 | "memberCode": "EDJ0" 631 | }, 632 | { 633 | "id": "ocd-person/703d6d4e-3a1d-4a2e-82e3-3c9693b75953", 634 | "name": "Liz Miranda", 635 | "givenName": "Liz", 636 | "familyName": "Miranda", 637 | "image": "https://malegislature.gov/Legislators/Profile/170/L%20M0.jpg", 638 | "links": [ 639 | { 640 | "url": "https://malegislature.gov/Legislators/Profile/L_M2" 641 | }, 642 | { 643 | "url": "https://malegislature.gov/Legislators/Profile/L M0" 644 | }, 645 | { 646 | "url": "https://malegislature.gov/Legislators/Profile/L%20M0" 647 | } 648 | ], 649 | "party": "Democratic", 650 | "district": "Second Suffolk", 651 | "email": "liz.miranda@masenate.gov", 652 | "phone": "617-722-1673", 653 | "url": "https://malegislature.gov/Legislators/Profile/L_M2", 654 | "memberCode": "L M0" 655 | }, 656 | { 657 | "id": "ocd-person/d2a82a0d-fbd1-4448-be2d-b786a7e11168", 658 | "name": "Adam Gómez", 659 | "givenName": "Adam", 660 | "familyName": "Gómez", 661 | "image": "https://malegislature.gov/Legislators/Profile/170/A_G0.jpg", 662 | "links": [ 663 | { 664 | "url": "https://malegislature.gov/Legislators/Profile/A_G0" 665 | } 666 | ], 667 | "party": "Democratic", 668 | "district": "Hampden", 669 | "email": "adam.gomez@masenate.gov", 670 | "phone": "617-722-1660", 671 | "url": "https://malegislature.gov/Legislators/Profile/A_G0", 672 | "memberCode": "A_G0" 673 | }, 674 | { 675 | "id": "ocd-person/b857e2f7-b65a-4d34-a680-50dfff745a16", 676 | "name": "Jake Oliveira", 677 | "givenName": "Jake", 678 | "familyName": "Oliveira", 679 | "image": "https://malegislature.gov/Legislators/Profile/170/JRO0.jpg", 680 | "links": [ 681 | { 682 | "url": "https://malegislature.gov/Legislators/Profile/JRO1" 683 | }, 684 | { 685 | "url": "https://malegislature.gov/Legislators/Profile/JRO0" 686 | } 687 | ], 688 | "party": "Democratic", 689 | "district": "Hampden, Hampshire and Worcester", 690 | "email": "jacob.oliveira@masenate.gov", 691 | "phone": "617-722-1291", 692 | "url": "https://malegislature.gov/Legislators/Profile/JRO1", 693 | "memberCode": "JRO0" 694 | }, 695 | { 696 | "id": "ocd-person/5a333327-70bb-4b95-9cfd-fadd701e9348", 697 | "name": "John Cronin", 698 | "givenName": "John", 699 | "familyName": "Cronin", 700 | "image": "https://malegislature.gov/Legislators/Profile/170/JJC0.jpg", 701 | "links": [ 702 | { 703 | "url": "https://malegislature.gov/Legislators/Profile/JJC0" 704 | } 705 | ], 706 | "party": "Democratic", 707 | "district": "Worcester and Middlesex", 708 | "email": "john.cronin@masenate.gov", 709 | "phone": "617-722-1230", 710 | "url": "https://malegislature.gov/Legislators/Profile/JJC0", 711 | "memberCode": "JJC0" 712 | }, 713 | { 714 | "id": "ocd-person/5278711f-55ec-4a67-acc9-edd1db1c4e8c", 715 | "name": "Lydia Edwards", 716 | "givenName": "Lydia", 717 | "familyName": "Edwards", 718 | "image": "https://malegislature.gov/Legislators/Profile/170/LME0.jpg", 719 | "links": [ 720 | { 721 | "url": "https://malegislature.gov/Legislators/Profile/LME0" 722 | } 723 | ], 724 | "party": "Democratic", 725 | "district": "Third Suffolk", 726 | "email": "lydia.edwards@masenate.gov", 727 | "phone": "617-722-1634", 728 | "url": "https://malegislature.gov/Legislators/Profile/LME0", 729 | "memberCode": "LME0" 730 | }, 731 | { 732 | "id": "ocd-person/2fb1c0e0-edaa-4b55-9bbb-b6fe6b3e5dcc", 733 | "name": "Robyn Kennedy", 734 | "givenName": "Robyn", 735 | "familyName": "Kennedy", 736 | "image": "https://malegislature.gov/Legislators/Profile/170/RKK0.jpg", 737 | "links": [ 738 | { 739 | "url": "https://malegislature.gov/Legislators/Profile/RKK0" 740 | } 741 | ], 742 | "party": "Democratic", 743 | "district": "First Worcester", 744 | "email": "robyn.kennedy@masenate.gov", 745 | "phone": "617-722-1544", 746 | "url": "https://malegislature.gov/Legislators/Profile/RKK0", 747 | "memberCode": "RKK0" 748 | }, 749 | { 750 | "id": "ocd-person/1d33d1e7-dae9-4e98-aa9e-626b0d711d9a", 751 | "name": "Pavel Payano", 752 | "givenName": "Pavel", 753 | "familyName": "Payano", 754 | "image": "https://malegislature.gov/Legislators/Profile/170/PMP0.jpg", 755 | "links": [ 756 | { 757 | "url": "https://malegislature.gov/Legislators/Profile/PMP0" 758 | } 759 | ], 760 | "party": "Democratic", 761 | "district": "First Essex", 762 | "email": "pavel.payano@masenate.gov", 763 | "phone": "617-722-1604", 764 | "url": "https://malegislature.gov/Legislators/Profile/PMP0", 765 | "memberCode": "PMP0" 766 | }, 767 | { 768 | "id": "ocd-person/c674060b-4568-43fa-b72d-0921f4876713", 769 | "name": "Peter Durant", 770 | "givenName": "Peter", 771 | "familyName": "Durant", 772 | "image": "https://malegislature.gov/Legislators/Profile/170/PJD0.jpg", 773 | "links": [ 774 | { 775 | "url": "https://malegislature.gov/Legislators/Profile/PJD0" 776 | } 777 | ], 778 | "party": "Republican", 779 | "district": "Worcester and Hampshire", 780 | "email": "peter.durant@masenate.gov", 781 | "phone": "617-722-1540", 782 | "url": "https://malegislature.gov/Legislators/Profile/PJD0", 783 | "memberCode": "PJD0" 784 | }, 785 | { 786 | "id": "ocd-person/062618e6-3ba4-4170-8b4f-9d0fa325a9ab", 787 | "name": "Kelly Dooner", 788 | "givenName": "Kelly", 789 | "familyName": "Dooner", 790 | "image": "https://malegislature.gov/Legislators/Profile/170/KAD0.jpg", 791 | "links": [ 792 | { 793 | "url": "https://malegislature.gov/Legislators/Profile/KAD0" 794 | } 795 | ], 796 | "party": "Republican", 797 | "district": "Third Bristol and Plymouth", 798 | "email": "kelly.dooner@masenate.gov", 799 | "phone": "617-722-1551", 800 | "url": "https://malegislature.gov/Legislators/Profile/KAD0", 801 | "memberCode": "KAD0" 802 | } 803 | ] --------------------------------------------------------------------------------