├── .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 |
9 | {children}
10 |
11 |
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 |
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 | {
28 | e.preventDefault()
29 | e.stopPropagation()
30 | }}>
31 |
36 | more information
37 |
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 |
14 | ) : (
15 |
20 | )
21 |
22 | const rotated = currentSort[0] === sort && currentSort[1] === 'desc'
23 |
24 | return (
25 | onClick(sort)}
30 | aria-label='sort'>
31 | {title}
32 | {image}
33 |
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 | 
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 | 
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 |
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 |
{
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 |
19 |
20 |
25 | Progressive Massachusetts Legislative Scorecard
26 |
27 |
28 |
59 |
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 |
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 |
7 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | This scorecard is the work of a volunteer-led team. If
37 | you spot an error, please contact us at{' '}
38 | corrections@progressivemass.com.
39 |
40 |
41 |
42 |
43 |
66 |
67 |
68 | Statehouse Dome image by{' '}
69 |
70 | David Ohmer
71 |
72 |
73 |
74 |
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 |
{
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 |
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 | toggleFilter(t)}>
32 | {getTagData(t).name}
33 |
34 |
35 | )
36 | })}
37 |
38 | >
39 | )
40 | }
41 |
42 | const RowTags = ({ tags = [], toggleFilter }) => (
43 |
44 | {tags.map((t, index) => {
45 | return (
46 |
47 | {
51 | toggleFilter(t)
52 | }}>
53 | {getTagData(t).name}
54 |
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 |
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 |
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 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | About Progressive Massachusetts
43 |
44 |
45 |
46 |
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 |
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 |
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 |
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 |
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 | {
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 |
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 |
188 | Filter By Legislator Name:
189 |
190 | {
196 | setFilter(e.target.value)
197 | setTimeout(forceCheck, 1)
198 | }}
199 | />
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
210 |
211 |
212 |
218 |
219 |
220 |
221 |
222 | {data.map((legislator, i) => (
223 |
230 | ))}
231 |
232 |
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 | ]
--------------------------------------------------------------------------------