├── .dokku-monorepo ├── .gitignore ├── LICENSE ├── README.md ├── frontend ├── .gitignore ├── config-overrides.js ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── google5a5f56e25db68875.html │ ├── index.html │ └── manifest.json ├── scripts │ └── get-leadership-data.js ├── src │ ├── app │ │ ├── App.css │ │ ├── App.tsx │ │ └── registerServiceWorker.js │ ├── feature │ │ ├── about │ │ │ ├── About.tsx │ │ │ └── about.md │ │ ├── map │ │ │ └── Map.tsx │ │ └── sidebar │ │ │ ├── Sidebar.tsx │ │ │ ├── district_info │ │ │ ├── ContactTab.tsx │ │ │ ├── DistrictInfo.tsx │ │ │ ├── LeadershipTab.tsx │ │ │ └── calendar │ │ │ │ ├── CalendarTab.tsx │ │ │ │ ├── EventDialog.tsx │ │ │ │ ├── SubscribeDialog.tsx │ │ │ │ └── subscribe-text.html │ │ │ ├── intro │ │ │ ├── Intro.tsx │ │ │ └── intro.md │ │ │ ├── search │ │ │ └── Search.tsx │ │ │ └── status │ │ │ └── Status.tsx │ ├── index.css │ ├── index.tsx │ └── shared │ │ ├── actions │ │ └── index.ts │ │ ├── components │ │ └── Html.tsx │ │ ├── constants.js │ │ ├── data │ │ ├── districts-geo.json │ │ ├── districts-info.json │ │ └── districts-leadership.json │ │ ├── google │ │ └── GCalApi.ts │ │ ├── icons │ │ └── Twitter.tsx │ │ ├── models │ │ ├── Calendar.ts │ │ ├── CalendarEvent.ts │ │ ├── ComponentSizes.ts │ │ ├── District.ts │ │ ├── DistrictLeadership.ts │ │ ├── Location.ts │ │ ├── RootState.ts │ │ └── Size.ts │ │ ├── polyfills.ts │ │ ├── reactGAMiddlewares.js │ │ ├── reducers │ │ ├── component-sizes.ts │ │ ├── index.ts │ │ └── selected-location.ts │ │ ├── selectors │ │ ├── district-id-from-route.ts │ │ └── location-from-route.ts │ │ ├── types │ │ ├── html.d.ts │ │ ├── json.d.ts │ │ ├── jss-extend.d.ts │ │ ├── mapbox-gl.ts │ │ ├── mapbox.d.ts │ │ ├── md.d.ts │ │ ├── promised-location.d.ts │ │ ├── react-jss.d.ts │ │ ├── react-resize-detector.ts │ │ └── swipeable-views.ts │ │ └── utils │ │ └── device.ts ├── static.json ├── tsconfig.json ├── tsconfig.test.json └── tslint.json └── scrapers ├── .gitignore ├── Pipfile ├── Pipfile.lock ├── Procfile ├── __init__.py ├── cbmap ├── __init__.py ├── items.py ├── jwtauth.py ├── pipelines.py ├── serialize.py ├── settings.py ├── spiders │ ├── __init__.py │ ├── bronx-cb2.py │ ├── bronx-cb5.py │ ├── bronx-cb9.py │ ├── brooklyn-cb1.py │ ├── brooklyn-cb2.py │ ├── brooklyn-cb3.py │ ├── brooklyn-cb6.py │ ├── brooklyn-cb9.py │ ├── manhattan-cb5.py │ ├── manhattan-cb7.py │ ├── queens-cb2.py │ ├── queens-cb3.py │ ├── queens-cb5.py │ └── queens-cb6.py └── utils.py ├── main.py ├── runtime.txt ├── scrapy.cfg └── server.py /.dokku-monorepo: -------------------------------------------------------------------------------- 1 | frontend=frontend 2 | scrapers=scrapers 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node build artifacts 2 | node_modules 3 | npm-debug.log 4 | 5 | # Local development 6 | *.env 7 | *.dev 8 | *.swp 9 | .DS_Store 10 | 11 | # Docker 12 | Dockerfile 13 | docker-compose.yml 14 | 15 | # Webstorm 16 | .idea 17 | 18 | frontend/public/scraper-data 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018-present 59Boards Contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 59Boards: NYC Community Board Map & Meetings Calendar Scraper 2 | 3 | New York City's government includes a system of 59 community board districts that offer a way for the public to get involved with local politics. Many New Yorkers don't know they exist, and the process to get involved can seem overwhelming. 4 | 5 | Most boards don't provide an easy way to get notified of upcoming meetings, making participation difficult. 59Boards [scrapes](https://en.wikipedia.org/wiki/Web_scraping) event information from board websites and provides a feed for your calendar app's subscription feature. 6 | 7 | ## Chat 8 | Join us in `#app-59boards` on the [Beta NYC Slack](http://slack.beta.nyc/). 9 | 10 | ## frontend 11 | 12 | ``` 13 | $ cd frontend 14 | $ npm install 15 | $ npm start 16 | ``` 17 | 18 | ## scrapers 19 | ``` 20 | $ ln -s ../../scrapers/output frontend/public/scraper-data 21 | $ cd scrapers 22 | $ pip install virtualenv 23 | $ virtualenv venv 24 | $ . venv/bin/activate 25 | $ pip install pipenv 26 | $ pipenv install 27 | $ python main.py --all 28 | ``` 29 | 30 | You'll also need [tabula-server](https://github.com/codebutler/tabula-server/) running on localhost:4000 for the PDF scrapers to work. 31 | 32 | ## License 33 | 34 | This project is licensed under the terms of the [MIT license](/LICENSE). 35 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | 23 | .idea 24 | 25 | -------------------------------------------------------------------------------- /frontend/config-overrides.js: -------------------------------------------------------------------------------- 1 | const paths = require('react-scripts-ts/config/paths'); 2 | 3 | const path = require("path"); 4 | const ruleChildren = (loader) => loader.use || loader.oneOf || Array.isArray(loader.loader) && loader.loader || [] 5 | 6 | const findIndexAndRules = (rulesSource, ruleMatcher) => { 7 | let result = undefined 8 | const rules = Array.isArray(rulesSource) ? rulesSource : ruleChildren(rulesSource) 9 | rules.some((rule, index) => result = ruleMatcher(rule) ? {index, rules} : findIndexAndRules(ruleChildren(rule), ruleMatcher)) 10 | return result 11 | } 12 | 13 | const createLoaderMatcher = (loader) => (rule) => rule.loader && rule.loader.indexOf(`${path.sep}${loader}${path.sep}`) !== -1 14 | const fileLoaderMatcher = createLoaderMatcher('file-loader') 15 | 16 | const addBeforeRule = (rulesSource, ruleMatcher, value) => { 17 | const {index, rules} = findIndexAndRules(rulesSource, ruleMatcher) 18 | rules.splice(index, 0, value) 19 | } 20 | 21 | module.exports = function override(config) { 22 | config.module.rules.push({ 23 | test: /\.(js|jsx)$/, 24 | include: paths.appSrc, 25 | loader: require.resolve('babel-loader'), 26 | options: { 27 | babelrc: false, 28 | presets: [require.resolve('babel-preset-react-app')], 29 | cacheDirectory: true, 30 | }, 31 | }); 32 | addBeforeRule(config.module.rules, fileLoaderMatcher, { 33 | test: /\.html$/, 34 | use: [ 35 | { loader: require.resolve('html-loader') } 36 | ] 37 | }); 38 | addBeforeRule(config.module.rules, fileLoaderMatcher, { 39 | test: /\.md$/, 40 | use: [ 41 | { loader: require.resolve('html-loader') }, 42 | { loader: require.resolve('markdown-loader') } 43 | ] 44 | }); 45 | return config; 46 | }; 47 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "59boards", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@mapbox/geo-viewport": "^0.2.2", 7 | "@turf/bbox": "^5.1.5", 8 | "@turf/boolean-point-in-polygon": "^5.1.5", 9 | "@turf/combine": "^5.1.5", 10 | "@turf/helpers": "^6.0.1", 11 | "@types/lodash": "^4.14.104", 12 | "array-flatten": "^2.1.1", 13 | "autosuggest-highlight": "^3.1.1", 14 | "core-js": "^2.5.3", 15 | "history": "^4.7.2", 16 | "html-loader": "^0.5.5", 17 | "immutable": "^3.8.2", 18 | "jss-extend": "^6.2.0", 19 | "lodash": "^4.17.5", 20 | "lodash-decorators": "latest", 21 | "mapbox": "^1.0.0-beta9", 22 | "mapbox-gl": "^0.44.1", 23 | "markdown-loader": "^2.0.2", 24 | "material-ui": "^1.0.0-beta.36", 25 | "material-ui-icons": "^1.0.0-beta.36", 26 | "moment": "^2.21.0", 27 | "npm": "^5.7.1", 28 | "promised-location": "^1.0.1", 29 | "raven-js": "^3.23.1", 30 | "react": "^16.2.0", 31 | "react-autosuggest": "^9.3.4", 32 | "react-dom": "^16.2.0", 33 | "react-ga": "^2.4.1", 34 | "react-redux": "^5.0.7", 35 | "react-resize-detector": "^2.2.0", 36 | "react-router-dom": "^4.2.2", 37 | "react-router-redux": "^5.0.0-alpha.9", 38 | "react-scripts": "^1.1.1", 39 | "react-scripts-ts": "^2.13.0", 40 | "react-swipeable-views": "^0.12.13", 41 | "redux": "^3.7.2", 42 | "redux-devtools-extension": "^2.13.2", 43 | "redux-logger": "^3.0.6", 44 | "redux-thunk": "^2.2.0", 45 | "typeface-roboto": "0.0.54", 46 | "typescript": "^2.7.2", 47 | "whatwg-fetch": "^2.0.3" 48 | }, 49 | "scripts": { 50 | "start": "react-app-rewired start --scripts-version react-scripts-ts", 51 | "build": "react-app-rewired build --scripts-version react-scripts-ts", 52 | "test": "react-app-rewired test --scripts-version react-scripts-ts --env=jsdom", 53 | "eject": "react-app-rewired eject" 54 | }, 55 | "devDependencies": { 56 | "@types/autosuggest-highlight": "^3.1.0", 57 | "@types/mapbox-gl": "^0.43.3", 58 | "@types/node": "^9.4.6", 59 | "@types/promise.prototype.finally": "^2.0.2", 60 | "@types/prop-types": "^15.5.2", 61 | "@types/react": "^16.0.40", 62 | "@types/react-autosuggest": "^9.3.3", 63 | "@types/react-dom": "^16.0.4", 64 | "@types/react-redux": "^5.0.15", 65 | "@types/react-router-dom": "^4.2.4", 66 | "@types/react-router-redux": "^5.0.12", 67 | "@types/react-swipeable-views": "^0.12.1", 68 | "@types/recompose": "^0.24.5", 69 | "@types/redux-logger": "^3.0.5", 70 | "@types/request": "^2.47.0", 71 | "@types/rest": "^1.3.28", 72 | "@types/through2": "^2.0.33", 73 | "JSONStream": "^1.3.2", 74 | "babel-plugin-transform-decorators": "^6.24.1", 75 | "csv-parser": "^1.12.0", 76 | "prop-types": "latest", 77 | "react-app-rewired": "^1.5.0", 78 | "redux-devtools": "^3.4.1", 79 | "request": "latest", 80 | "through2": "latest", 81 | "through2-map": "^3.0.0", 82 | "through2-reduce": "^1.1.1" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codebutler/59boards/fc7255aac18d67e08b4ae20c671540a6f80dc6e3/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/google5a5f56e25db68875.html: -------------------------------------------------------------------------------- 1 | google-site-verification: google5a5f56e25db68875.html -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 59Boards - NYC Community Board Map 17 | 18 | 19 | 22 |
23 | 24 | 25 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "59Boards", 3 | "name": "59Boards: NYC Community Board Map", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /frontend/scripts/get-leadership-data.js: -------------------------------------------------------------------------------- 1 | const request = require('request'); 2 | const csv = require('csv-parser'); 3 | const t2map = require('through2-map'); 4 | const _ = require('lodash'); 5 | const fs = require('fs'); 6 | const path = require('path'); 7 | const JSONStream = require('JSONStream'); 8 | 9 | const OUTPUT_DIR = path.join(path.dirname(__filename), '..', 'src', 'shared', 'data'); 10 | const DISTRICT_INFO = require(path.join(OUTPUT_DIR, 'districts-info.json')); 11 | 12 | const findDistrict = (borough, number) => _.find(DISTRICT_INFO, district => 13 | district.borough.toLowerCase() === borough.toLowerCase() 14 | && parseInt(district.number) === parseInt(number)); 15 | 16 | // Community Board Leadership (Manhattan) 17 | // https://data.cityofnewyork.us/City-Government/Community-Board-Leadership/3gkd-ddzn 18 | 19 | request.get('https://data.cityofnewyork.us/api/views/3gkd-ddzn/rows.csv?accessType=DOWNLOAD') 20 | .pipe(csv()) 21 | .pipe(t2map.obj(item => [ 22 | findDistrict( 23 | item['Borough'] || 'Manhattan', 24 | item['Community Board'] 25 | )['id'].toString(), 26 | _(item) 27 | .mapKeys((value, key) => _.camelCase(key)) 28 | .mapValues((value) => value.trim())])) 29 | .pipe(JSONStream.stringifyObject('{\n', ',\n', '\n}\n', ' ')) 30 | .pipe(fs.createWriteStream(path.join(OUTPUT_DIR, 'districts-leadership.json'))); 31 | -------------------------------------------------------------------------------- /frontend/src/app/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | position: absolute; 3 | overflow: hidden; 4 | top: 0; 5 | left: 0; 6 | width: 100%; 7 | height: 100%; 8 | } 9 | 10 | .App-logo { 11 | animation: App-logo-spin infinite 20s linear; 12 | height: 80px; 13 | } 14 | 15 | .App-header { 16 | background-color: #222; 17 | height: 150px; 18 | padding: 20px; 19 | color: white; 20 | } 21 | 22 | .App-title { 23 | font-size: 1.5em; 24 | } 25 | 26 | .App-intro { 27 | font-size: large; 28 | } 29 | 30 | .mapboxgl-map { 31 | position: absolute !important; 32 | top: 0 !important; 33 | right: 0 !important; 34 | left: 0 !important; 35 | bottom: 0 !important; 36 | } 37 | 38 | .mapboxgl-popup-content { 39 | pointer-events: none !important; 40 | } 41 | 42 | -------------------------------------------------------------------------------- /frontend/src/app/App.tsx: -------------------------------------------------------------------------------- 1 | import MapboxClient from 'mapbox'; 2 | import Reboot from 'material-ui/Reboot'; 3 | import PropTypes from 'prop-types'; 4 | import React, { Component } from 'react'; 5 | import './App.css'; 6 | import Map from '../feature/map/Map'; 7 | import Sidebar from '../feature/sidebar/Sidebar'; 8 | import { MAPBOX_TOKEN } from '../shared/constants'; 9 | import ReactResizeDetector from 'react-resize-detector'; 10 | import { connect } from 'react-redux'; 11 | import { componentResized, RootAction } from '../shared/actions'; 12 | import { Dispatch } from 'redux'; 13 | import { Route, RouteComponentProps, withRouter } from 'react-router'; 14 | import About from '../feature/about/About'; 15 | import { create } from 'jss'; 16 | import JssProvider from 'react-jss/lib/JssProvider'; 17 | import { createGenerateClassName, jssPreset } from 'material-ui/styles'; 18 | import jssExtend from 'jss-extend'; 19 | import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider'; 20 | import createMuiTheme from 'material-ui/styles/createMuiTheme'; 21 | import indigo from 'material-ui/colors/indigo'; 22 | import deepOrange from 'material-ui/colors/deepOrange'; 23 | import withStyles from 'material-ui/styles/withStyles'; 24 | import { WithStyles } from 'material-ui'; 25 | import ReactGA from 'react-ga'; 26 | 27 | const mapboxClient = new MapboxClient(MAPBOX_TOKEN); 28 | 29 | export interface AppContext { 30 | mapboxClient: MapboxClient; 31 | } 32 | 33 | interface DispatchProps { 34 | onResize: (width: number, height: number) => void; 35 | } 36 | 37 | type Props = DispatchProps; 38 | type PropsWithRoute = Props & RouteComponentProps<{}>; 39 | type PropsWithRouteAndStyle = PropsWithRoute & WithStyles<'@global'>; 40 | 41 | // Configure JSS 42 | const jss = create({ plugins: [jssExtend(), ...jssPreset().plugins] }); 43 | 44 | // Custom Material-UI class name generator. 45 | const generateClassName = createGenerateClassName(); 46 | 47 | const theme = createMuiTheme({ 48 | palette: { 49 | primary: {...indigo}, 50 | secondary: {...deepOrange} 51 | } 52 | }); 53 | 54 | ReactGA.initialize('UA-115583455-1'); 55 | 56 | class App extends Component { 57 | 58 | static childContextTypes = { 59 | mapboxClient: PropTypes.instanceOf(MapboxClient).isRequired 60 | }; 61 | 62 | constructor(props: PropsWithRouteAndStyle) { 63 | super(props); 64 | this.state = {}; 65 | } 66 | 67 | getChildContext(): AppContext { 68 | return { mapboxClient }; 69 | } 70 | 71 | render() { 72 | return ( 73 | 74 | 75 |
76 | 77 | 78 | 79 | 80 | 85 |
86 |
87 |
88 | ); 89 | } 90 | } 91 | 92 | const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => { 93 | return { 94 | onResize: (width: number, height: number) => { 95 | dispatch(componentResized('app', { width, height })); 96 | } 97 | }; 98 | }; 99 | 100 | const styles = () => ({ 101 | '@global': { 102 | 'a': { 103 | color: theme.palette.secondary.main, 104 | textDecoration: 'none', 105 | '&:hover': { 106 | textDecoration: 'underline', 107 | } 108 | } 109 | } 110 | }); 111 | 112 | export default withRouter( 113 | connect( 114 | null, 115 | mapDispatchToProps 116 | )( 117 | withStyles(styles)(App) 118 | ) 119 | ); 120 | -------------------------------------------------------------------------------- /frontend/src/app/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | // In production, we register a service worker to serve assets from local cache. 2 | 3 | // This lets the app load faster on subsequent visits in production, and gives 4 | // it offline capabilities. However, it also means that developers (and users) 5 | // will only see deployed updates on the "N+1" visit to a page, since previously 6 | // cached resources are updated in the background. 7 | 8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 9 | // This link also includes instructions on opting out of this behavior. 10 | 11 | const isLocalhost = Boolean( 12 | window.location.hostname === 'localhost' || 13 | // [::1] is the IPv6 localhost address. 14 | window.location.hostname === '[::1]' || 15 | // 127.0.0.1/8 is considered localhost for IPv4. 16 | window.location.hostname.match( 17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 18 | ) 19 | ); 20 | 21 | export default function register() { 22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 23 | // The URL constructor is available in all browsers that support SW. 24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location); 25 | if (publicUrl.origin !== window.location.origin) { 26 | // Our service worker won't work if PUBLIC_URL is on a different origin 27 | // from what our page is served on. This might happen if a CDN is used to 28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 29 | return; 30 | } 31 | 32 | window.addEventListener('load', () => { 33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 34 | 35 | if (isLocalhost) { 36 | // This is running on localhost. Lets check if a service worker still exists or not. 37 | checkValidServiceWorker(swUrl); 38 | 39 | // Add some additional logging to localhost, pointing developers to the 40 | // service worker/PWA documentation. 41 | navigator.serviceWorker.ready.then(() => { 42 | console.log( 43 | 'This web app is being served cache-first by a service ' + 44 | 'worker. To learn more, visit https://goo.gl/SC7cgQ' 45 | ); 46 | }); 47 | } else { 48 | // Is not local host. Just register service worker 49 | registerValidSW(swUrl); 50 | } 51 | }); 52 | } 53 | } 54 | 55 | function registerValidSW(swUrl) { 56 | navigator.serviceWorker 57 | .register(swUrl) 58 | .then(registration => { 59 | registration.onupdatefound = () => { 60 | const installingWorker = registration.installing; 61 | installingWorker.onstatechange = () => { 62 | if (installingWorker.state === 'installed') { 63 | if (navigator.serviceWorker.controller) { 64 | // At this point, the old content will have been purged and 65 | // the fresh content will have been added to the cache. 66 | // It's the perfect time to display a "New content is 67 | // available; please refresh." message in your web app. 68 | console.log('New content is available; please refresh.'); 69 | } else { 70 | // At this point, everything has been precached. 71 | // It's the perfect time to display a 72 | // "Content is cached for offline use." message. 73 | console.log('Content is cached for offline use.'); 74 | } 75 | } 76 | }; 77 | }; 78 | }) 79 | .catch(error => { 80 | console.error('Error during service worker registration:', error); 81 | }); 82 | } 83 | 84 | function checkValidServiceWorker(swUrl) { 85 | // Check if the service worker can be found. If it can't reload the page. 86 | fetch(swUrl) 87 | .then(response => { 88 | // Ensure service worker exists, and that we really are getting a JS file. 89 | if ( 90 | response.status === 404 || 91 | response.headers.get('content-type').indexOf('javascript') === -1 92 | ) { 93 | // No service worker found. Probably a different app. Reload the page. 94 | navigator.serviceWorker.ready.then(registration => { 95 | registration.unregister().then(() => { 96 | window.location.reload(); 97 | }); 98 | }); 99 | } else { 100 | // Service worker found. Proceed as normal. 101 | registerValidSW(swUrl); 102 | } 103 | }) 104 | .catch(() => { 105 | console.log( 106 | 'No internet connection found. App is running in offline mode.' 107 | ); 108 | }); 109 | } 110 | 111 | export function unregister() { 112 | if ('serviceWorker' in navigator) { 113 | navigator.serviceWorker.ready.then(registration => { 114 | registration.unregister(); 115 | }); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /frontend/src/feature/about/About.tsx: -------------------------------------------------------------------------------- 1 | import { Component, default as React } from 'react'; 2 | import Dialog, { DialogActions, DialogContent, DialogContentText, DialogTitle } from 'material-ui/Dialog'; 3 | import Button from 'material-ui/Button'; 4 | import { connect } from 'react-redux'; 5 | import { RootAction } from '../../shared/actions'; 6 | import { Dispatch } from 'redux'; 7 | import { push } from 'react-router-redux'; 8 | import { bind } from 'lodash-decorators/bind'; 9 | import Html from '../../shared/components/Html'; 10 | import AboutHtml from './about.md'; 11 | 12 | interface DispatchProps { 13 | onDialogExited: () => void; 14 | } 15 | 16 | type Props = DispatchProps; 17 | 18 | interface State { 19 | isOpen: boolean; 20 | } 21 | 22 | class About extends Component { 23 | 24 | constructor(props: Props) { 25 | super(props); 26 | this.state = { isOpen: true }; 27 | } 28 | 29 | render() { 30 | return ( 31 | 36 | About 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | ); 47 | } 48 | 49 | @bind() 50 | private onDialogClose() { 51 | this.setState({ isOpen: false }); 52 | } 53 | } 54 | 55 | const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => { 56 | return { 57 | onDialogExited: () => { 58 | dispatch(push('/')); 59 | } 60 | }; 61 | }; 62 | 63 | export default connect( 64 | null, 65 | mapDispatchToProps 66 | )(About); 67 | -------------------------------------------------------------------------------- /frontend/src/feature/about/about.md: -------------------------------------------------------------------------------- 1 | Created By Eric Butler 2 | 3 | * [@codebutler](https://twitter.com/codebutler) 4 | * [GitHub](https://github.com/codebutler/59boards) 5 | -------------------------------------------------------------------------------- /frontend/src/feature/map/Map.tsx: -------------------------------------------------------------------------------- 1 | import bbox from '@turf/bbox'; 2 | import { Feature, FeatureCollection, GeoJsonProperties, Polygon } from 'geojson'; 3 | import mapboxgl from 'mapbox-gl'; 4 | import React, { Component } from 'react'; 5 | import { connect } from 'react-redux'; 6 | import { Dispatch } from 'redux'; 7 | import { RootAction, selectDistrict } from '../../shared/actions'; 8 | import { DUMMY_BORO_IDS, MAPBOX_TOKEN, NYC_BOUNDING_BOX } from '../../shared/constants'; 9 | import Location from '../../shared/models/Location'; 10 | import { RootState } from '../../shared/models/RootState'; 11 | import withWidth, { WithWidthProps } from 'material-ui/utils/withWidth'; 12 | import { Breakpoint } from 'material-ui/styles/createBreakpoints'; 13 | import { bind, debounce } from 'lodash-decorators'; 14 | import districtIdFromRoute from '../../shared/selectors/district-id-from-route'; 15 | import DISTRICTS_GEOJSON from '../../shared/data/districts-geo.json'; 16 | import EventData = mapboxgl.EventData; 17 | import LngLatLike = mapboxgl.LngLatLike; 18 | import MapMouseEvent = mapboxgl.MapMouseEvent; 19 | import Marker = mapboxgl.Marker; 20 | import Point = mapboxgl.Point; 21 | import LngLatBoundsLike = mapboxgl.LngLatBoundsLike; 22 | import { isMobileSafari } from '../../shared/utils/device'; 23 | 24 | mapboxgl.accessToken = MAPBOX_TOKEN; 25 | 26 | interface StateProps { 27 | selectedLocation: Location; 28 | selectedDistrictId: number; 29 | appSize: Size; 30 | sidebarSize?: Size; 31 | } 32 | 33 | interface DispatchProps { 34 | onDistrictSelected: (district: number) => void; 35 | } 36 | 37 | type Props = StateProps & DispatchProps; 38 | type PropsWithStyles = Props & WithWidthProps; 39 | 40 | interface State { 41 | hoveredFeature?: Feature; 42 | hoveredPoint?: Point; 43 | hoveredLngLat?: LngLatLike; 44 | mapLoaded: boolean; 45 | } 46 | 47 | class Map extends Component { 48 | 49 | map: mapboxgl.Map; 50 | popup: mapboxgl.Popup; 51 | mapContainer: HTMLDivElement | null; 52 | locationMarker: Marker; 53 | 54 | private static boroDisplayText(boroCD: number) { 55 | const districtNum = parseInt(boroCD.toString().substr(1), 10); 56 | const boroIndex = boroCD.toString()[0]; 57 | const boroNames = { 1: 'Manhattan', 2: 'Bronx', 3: 'Brooklyn', 4: 'Queens', 5: 'Staten Island'}; 58 | return `${boroNames[boroIndex]} CB${districtNum}`; 59 | } 60 | 61 | private static getMapPadding(width: Breakpoint, sidebarSize?: Size): mapboxgl.PaddingOptions { 62 | const defaultPadding = 20; 63 | const topPadding = (width === 'xs' && sidebarSize) 64 | ? defaultPadding + sidebarSize.height 65 | : defaultPadding; 66 | const leftPadding = (width !== 'xs' && sidebarSize) 67 | ? defaultPadding + sidebarSize.width 68 | : defaultPadding; 69 | return { 70 | top: topPadding, 71 | right: defaultPadding, 72 | bottom: defaultPadding, 73 | left: leftPadding 74 | }; 75 | } 76 | 77 | constructor(props: PropsWithStyles) { 78 | super(props); 79 | 80 | this.state = { 81 | mapLoaded: false 82 | }; 83 | } 84 | 85 | componentDidMount() { 86 | const map = this.map = new mapboxgl.Map({ 87 | container: this.mapContainer!, 88 | style: 'mapbox://styles/mapbox/light-v9', 89 | center: [-74.0060, 40.7128], 90 | zoom: 11 91 | }); 92 | 93 | this.popup = new mapboxgl.Popup({ 94 | closeButton: false, 95 | closeOnClick: false 96 | }); 97 | 98 | this.locationMarker = new mapboxgl.Marker(null!!, {}); 99 | 100 | map.on('load', this.onMapLoad); 101 | 102 | map.on('click', 'district-fills', (e: MapMouseEvent) => { 103 | this.setState({ 104 | hoveredFeature: undefined, 105 | hoveredPoint: undefined, 106 | hoveredLngLat: undefined, 107 | }); 108 | 109 | const districtId = e.features[0].properties!.BoroCD; 110 | this.props.onDistrictSelected(districtId); 111 | }); 112 | 113 | // https://stackoverflow.com/a/46051711 114 | if (!isMobileSafari()) { 115 | map.on('mouseenter', 'district-fills', () => { 116 | map.getCanvas().style.cursor = 'pointer'; 117 | }); 118 | 119 | map.on('mouseleave', 'district-fills', () => { 120 | map.getCanvas().style.cursor = ''; 121 | }); 122 | 123 | map.on('mousemove', 'district-fills', (e: MapMouseEvent) => { 124 | this.setState({ 125 | hoveredFeature: e.features[0], 126 | hoveredPoint: e.point, 127 | hoveredLngLat: e.lngLat 128 | }); 129 | }); 130 | 131 | map.on('mouseleave', 'district-fills', (e: MapMouseEvent) => { 132 | this.setState({ 133 | hoveredFeature: undefined, 134 | hoveredPoint: undefined, 135 | hoveredLngLat: undefined, 136 | }); 137 | }); 138 | } 139 | } 140 | 141 | componentWillUpdate(nextProps: PropsWithStyles, nextState: State) { 142 | const curProps = this.props; 143 | const curState = this.state; 144 | 145 | if (!nextState.mapLoaded) { 146 | return; 147 | } 148 | 149 | const { hoveredFeature, hoveredLngLat } = nextState; 150 | if (hoveredFeature) { 151 | const boroCd = hoveredFeature.properties!.BoroCD; 152 | this.map.setFilter('district-fills-hover', ['==', 'BoroCD', boroCd]); 153 | } else { 154 | this.map.setFilter('district-fills-hover', ['==', 'BoroCD', '']); 155 | } 156 | 157 | const {selectedLocation, selectedDistrictId} = nextProps; 158 | if (selectedLocation) { 159 | this.locationMarker.setLngLat(selectedLocation.center); 160 | this.locationMarker.addTo(this.map); 161 | } else { 162 | this.locationMarker.remove(); 163 | } 164 | 165 | const filter = ['==', 'BoroCD', selectedDistrictId ? selectedDistrictId : '']; 166 | this.map.setFilter('district-borders-selected', filter); 167 | this.map.setFilter('district-fills-selected', filter); 168 | 169 | if (hoveredFeature && hoveredLngLat) { 170 | this.popup 171 | .setLngLat(hoveredLngLat) 172 | .setHTML(Map.boroDisplayText(hoveredFeature.properties!.BoroCD)) 173 | .addTo(this.map); 174 | } else { 175 | this.popup.remove(); 176 | } 177 | 178 | const oldSidebarSize = curProps.sidebarSize; 179 | const sidebarSize = nextProps.sidebarSize; 180 | 181 | const oldWidth = curProps.width; 182 | const width = nextProps.width; 183 | 184 | const oldSelectedDistrictId = curProps.selectedDistrictId; 185 | 186 | const oldAppHeight = curProps.appSize.height; 187 | const appHeight = nextProps.appSize.height; 188 | 189 | if (selectedDistrictId !== oldSelectedDistrictId || 190 | sidebarSize !== oldSidebarSize || 191 | width !== oldWidth || 192 | oldAppHeight !== appHeight || 193 | nextState.mapLoaded !== curState.mapLoaded) { 194 | 195 | if (selectedDistrictId) { 196 | const featureCollection = DISTRICTS_GEOJSON as FeatureCollection; 197 | const districtFeatures = featureCollection.features 198 | .filter((feature: Feature) => { 199 | return feature.properties!.BoroCD === selectedDistrictId; 200 | }); 201 | if (districtFeatures.length <= 0) { 202 | return; 203 | } 204 | const bounds = new mapboxgl.LngLatBounds(); 205 | districtFeatures.forEach((feature: Feature) => { 206 | const [minX, minY, maxX, maxY] = bbox(feature); 207 | bounds.extend(new mapboxgl.LngLatBounds([minX, minY], [maxX, maxY])); 208 | }); 209 | this.fitMapBounds(bounds, nextProps); 210 | } else { 211 | this.fitMapBounds(NYC_BOUNDING_BOX, nextProps); 212 | } 213 | } 214 | } 215 | 216 | render() { 217 | return ( 218 |
this.mapContainer = el} /> 219 | ); 220 | } 221 | 222 | @bind() 223 | @debounce(100) 224 | private fitMapBounds(bounds: LngLatBoundsLike, props: PropsWithStyles) { 225 | this.map.fitBounds(bounds, { 226 | padding: Map.getMapPadding(props.width, props.sidebarSize), 227 | duration: 500 228 | }); 229 | } 230 | 231 | @bind() 232 | private onMapLoad(event: EventData) { 233 | const map = event.target; 234 | 235 | map.addSource('districts', { 236 | type: 'geojson', 237 | data: DISTRICTS_GEOJSON 238 | }); 239 | 240 | map.addLayer({ 241 | 'id': 'district-fills', 242 | 'type': 'fill', 243 | 'source': 'districts', 244 | 'layout': {}, 245 | 'paint': { 246 | 'fill-color': '#6d74b6', 247 | 'fill-opacity': 0.12 248 | }, 249 | 'filter': ['!in', 'BoroCD', ...DUMMY_BORO_IDS] 250 | }); 251 | 252 | map.addLayer({ 253 | 'id': 'district-fills-hover', 254 | 'type': 'fill', 255 | 'source': 'districts', 256 | 'layout': {}, 257 | 'paint': { 258 | 'fill-color': '#6d74b6', 259 | 'fill-opacity': 0.4 260 | }, 261 | 'filter': ['==', 'BoroCD', ''] 262 | }); 263 | 264 | map.addLayer({ 265 | 'id': 'district-fills-selected', 266 | 'type': 'fill', 267 | 'source': 'districts', 268 | 'layout': {}, 269 | 'paint': { 270 | 'fill-color': '#b66d6d', 271 | 'fill-opacity': 0.4 272 | }, 273 | 'filter': ['==', 'BoroCD', ''] 274 | }); 275 | 276 | map.addLayer({ 277 | 'id': 'district-borders', 278 | 'type': 'line', 279 | 'source': 'districts', 280 | 'layout': {}, 281 | 'paint': { 282 | 'line-color': '#1f336b', 283 | 'line-width': 1 284 | }, 285 | 'filter': ['!in', 'BoroCD', ...DUMMY_BORO_IDS] 286 | }); 287 | 288 | map.addLayer({ 289 | 'id': 'district-borders-selected', 290 | 'type': 'line', 291 | 'source': 'districts', 292 | 'layout': {}, 293 | 'paint': { 294 | 'line-color': '#6b1f1f', 295 | 'line-width': 2 296 | }, 297 | 'filter': ['==', 'BoroCD', ''] 298 | }); 299 | 300 | this.setState({ mapLoaded: true }); 301 | } 302 | } 303 | 304 | const mapStateToProps = (state: RootState): StateProps => { 305 | const selectedDistrictId = districtIdFromRoute(state)!; 306 | return { 307 | selectedLocation: state.selectedLocation!, 308 | selectedDistrictId: selectedDistrictId, 309 | appSize: state.componentSizes.app, 310 | sidebarSize: state.componentSizes.sidebar 311 | }; 312 | }; 313 | 314 | const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => { 315 | return { 316 | onDistrictSelected: (districtId: number) => { 317 | dispatch(selectDistrict(districtId)); 318 | } 319 | }; 320 | }; 321 | 322 | export default connect( 323 | mapStateToProps, 324 | mapDispatchToProps 325 | )(withWidth()(Map)); 326 | -------------------------------------------------------------------------------- /frontend/src/feature/sidebar/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { bind } from 'lodash-decorators'; 2 | import React, { Component } from 'react'; 3 | import { connect } from 'react-redux'; 4 | import { Dispatch } from 'redux'; 5 | import { clearSelection, componentResized, RootAction } from '../../shared/actions'; 6 | import { RootState } from '../../shared/models/RootState'; 7 | import DistrictInfo from './district_info/DistrictInfo'; 8 | import Intro from './intro/Intro'; 9 | import Search from './search/Search'; 10 | import withStyles from 'material-ui/styles/withStyles'; 11 | import { Theme } from 'material-ui/styles'; 12 | import { WithStyles } from 'material-ui'; 13 | import ReactResizeDetector from 'react-resize-detector'; 14 | import { Route, RouteComponentProps, Switch, withRouter } from 'react-router'; 15 | import Status from './status/Status'; 16 | import districtIdFromRoute from '../../shared/selectors/district-id-from-route'; 17 | import withWidth, { WithWidthProps } from 'material-ui/utils/withWidth'; 18 | import { isMobileSafari } from '../../shared/utils/device'; 19 | 20 | interface StateProps { 21 | selectedDistrictId?: number; 22 | } 23 | 24 | interface DispatchProps { 25 | onClearSelection: () => void; 26 | onResize: (width: number, height: number) => void; 27 | } 28 | 29 | interface State { 30 | isSearchFocused: boolean; 31 | } 32 | 33 | type ClassKey = 34 | | 'sidebar' 35 | | 'introContainer'; 36 | 37 | type Props = StateProps & DispatchProps; 38 | type PropsWithRoute = Props & RouteComponentProps<{}>; 39 | type PropsWithStyles = PropsWithRoute & WithStyles & WithWidthProps; 40 | 41 | class Sidebar extends Component { 42 | 43 | introContainer: HTMLElement | null; 44 | 45 | constructor(props: PropsWithStyles) { 46 | super(props); 47 | this.state = { isSearchFocused: false }; 48 | } 49 | 50 | render() { 51 | const { classes, onResize } = this.props; 52 | return ( 53 |
54 | 55 | 56 | 60 | {!this.props.selectedDistrictId && ( 61 |
this.introContainer = el} className={classes.introContainer}> 62 | 63 |
64 | )} 65 | {this.props.selectedDistrictId && ( 66 | 67 | )} 68 | {!this.props.selectedDistrictId && ( 69 | 70 | )} 71 | 72 | ) 73 | } 74 | /> 75 |
76 | 81 |
82 | ); 83 | } 84 | 85 | componentDidUpdate() { 86 | const isMobile = (this.props.width) === 'xs'; 87 | const hideIntroForMobile = this.state.isSearchFocused && isMobile && !isMobileSafari(); 88 | if (this.introContainer) { 89 | if (hideIntroForMobile) { 90 | this.introContainer.style.marginTop = `${-this.introContainer.clientHeight}px`; 91 | } else { 92 | this.introContainer.style.marginTop = '0px'; 93 | } 94 | } 95 | } 96 | 97 | @bind() 98 | private onCloseClicked() { 99 | this.props.onClearSelection(); 100 | } 101 | 102 | @bind() 103 | private onSearchFocusChanged(isFocused: boolean) { 104 | this.setState({ isSearchFocused: isFocused }); 105 | } 106 | } 107 | 108 | const mapStateToProps = (state: RootState): StateProps => { 109 | return { 110 | selectedDistrictId: districtIdFromRoute(state) 111 | }; 112 | }; 113 | 114 | const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => { 115 | return { 116 | onClearSelection: () => { 117 | dispatch(clearSelection()); 118 | }, 119 | onResize: (width: number, height: number) => { 120 | dispatch(componentResized('sidebar', { width, height })); 121 | } 122 | }; 123 | }; 124 | 125 | const styles = (theme: Theme) => ({ 126 | sidebar: { 127 | position: 'absolute' as 'absolute', 128 | maxHeight: '100%', 129 | overflow: 'scroll' as 'scroll', 130 | zIndex: 1, 131 | width: 392, 132 | maxWidth: '100%', 133 | padding: 8, 134 | [theme.breakpoints.down('xs')]: { 135 | width: '100%' 136 | } 137 | }, 138 | introContainer: { 139 | transition: 'margin 300ms' 140 | } 141 | }); 142 | 143 | export default withRouter( 144 | connect( 145 | mapStateToProps, 146 | mapDispatchToProps 147 | )( 148 | withStyles(styles)(withWidth()(Sidebar)) 149 | ) 150 | ); 151 | -------------------------------------------------------------------------------- /frontend/src/feature/sidebar/district_info/ContactTab.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import LinkIcon from 'material-ui-icons/Link'; 3 | import PhoneIcon from 'material-ui-icons/Phone'; 4 | import PlaceIcon from 'material-ui-icons/Place'; 5 | import EmailIcon from 'material-ui-icons/Email'; 6 | import TwitterIcon from '../../../shared/icons/Twitter'; 7 | import { List, ListItem, ListItemIcon, ListItemText, WithStyles } from 'material-ui'; 8 | import withStyles from 'material-ui/styles/withStyles'; 9 | import { Theme } from 'material-ui/styles'; 10 | import District from '../../../shared/models/District'; 11 | 12 | interface Props { 13 | district: District; 14 | } 15 | 16 | type ClassKey = 17 | | 'contactList' 18 | | 'contactListItemText'; 19 | 20 | type PropsWithStyles = Props & WithStyles; 21 | 22 | class ContactTab extends Component { 23 | 24 | render() { 25 | const { classes, district } = this.props; 26 | return ( 27 | 31 | {district.address && ( 32 | 38 | 39 | 40 | 41 | 45 | 46 | )} 47 | { district.website && ( 48 | 54 | 55 | 56 | 57 | 61 | 62 | )} 63 | { district.twitter && ( 64 | 70 | 71 | 72 | 73 | 77 | 78 | )} 79 | { district.email && ( 80 | 86 | 87 | 88 | 89 | 93 | 94 | )} 95 | { district.phone && ( 96 | 101 | 102 | 103 | 104 | 108 | 109 | )} 110 | 111 | ); 112 | } 113 | } 114 | 115 | const styles = (theme: Theme) => ( 116 | { 117 | contactList: { 118 | overflow: 'hidden' as 'hidden', 119 | whiteSpace: 'nowrap' as 'nonowrap', 120 | }, 121 | contactListItemText: { 122 | maskImage: 'linear-gradient(left, white 80%, rgba(255,255,255,0) 100%)' 123 | }, 124 | }); 125 | 126 | export default withStyles(styles)(ContactTab); 127 | -------------------------------------------------------------------------------- /frontend/src/feature/sidebar/district_info/DistrictInfo.tsx: -------------------------------------------------------------------------------- 1 | import { WithStyles } from 'material-ui'; 2 | import CloseIcon from 'material-ui-icons/Close'; 3 | import Card, { CardContent, CardHeader } from 'material-ui/Card'; 4 | import IconButton from 'material-ui/IconButton'; 5 | import { Theme } from 'material-ui/styles'; 6 | import withStyles from 'material-ui/styles/withStyles'; 7 | import Tabs, { Tab } from 'material-ui/Tabs'; 8 | import React, { Component } from 'react'; 9 | import { connect } from 'react-redux'; 10 | import DISTRICTS from '../../../shared/data/districts-info.json'; 11 | import District from '../../../shared/models/District'; 12 | import { RootState } from '../../../shared/models/RootState'; 13 | import SwipeableViews from 'react-swipeable-views'; 14 | import districtIdFromRoute from '../../../shared/selectors/district-id-from-route'; 15 | import ContactTab from './ContactTab'; 16 | import CalendarTab from './calendar/CalendarTab'; 17 | import LeadershipTab from './LeadershipTab'; 18 | 19 | interface OwnProps { 20 | onCloseInfoClicked: () => void; 21 | } 22 | 23 | interface StateProps { 24 | district: District; 25 | } 26 | 27 | type ClassKey = 28 | | 'card' 29 | | 'cardHeader' 30 | | 'cardContent' 31 | | 'title' 32 | | 'tab'; 33 | 34 | type Props = OwnProps & StateProps; 35 | type PropsWithStyles = Props & WithStyles; 36 | 37 | interface State { 38 | selectedTab: number; 39 | } 40 | 41 | const SHOW_LEADERSHIP = false; // This feature isn't quite ready 42 | 43 | class DistrictInfo extends Component { 44 | 45 | constructor(props: PropsWithStyles) { 46 | super(props); 47 | this.state = { selectedTab: 0 }; 48 | } 49 | 50 | render() { 51 | const { classes, district } = this.props; 52 | if (district) { 53 | return ( 54 | 55 | 59 | 60 | 61 | )} 62 | title={`${district.borough} CB${district.number}`} 63 | subheader={district.neighborhoods} 64 | /> 65 | 66 | this.setState({ selectedTab: value })} 69 | indicatorColor="primary" 70 | textColor="primary" 71 | fullWidth={true} 72 | > 73 | 74 | {SHOW_LEADERSHIP && } 75 | 76 | 77 | {SHOW_LEADERSHIP ? ( 78 | { this.setState({selectedTab: index}); }} 82 | > 83 | 84 | 85 | 86 | 87 | ) : ( 88 | { this.setState({selectedTab: index}); }} 92 | > 93 | 94 | 95 | 96 | )} 97 | 98 | 99 | ); 100 | } else { 101 | return ( 102 | 103 | 106 | 107 | 108 | )} 109 | title="No Community Board" 110 | /> 111 | 112 | ); 113 | } 114 | } 115 | } 116 | 117 | const styles = (theme: Theme) => ( 118 | { 119 | card: { 120 | marginBottom: theme.spacing.unit 121 | }, 122 | cardHeader: { 123 | paddingBottom: 0, 124 | }, 125 | cardContent: { 126 | padding: 0, 127 | '&:last-child': { 128 | paddingBottom: theme.spacing.unit 129 | } 130 | }, 131 | title: { 132 | marginBottom: 16, 133 | fontSize: 14, 134 | color: theme.palette.text.secondary 135 | }, 136 | tab: { 137 | [theme.breakpoints.up('md')]: { 138 | minWidth: 'inherit', 139 | }, 140 | } 141 | } 142 | ); 143 | 144 | const mapStateToProps = (state: RootState): StateProps => { 145 | const selectedDistrictId = districtIdFromRoute(state)!; 146 | return { 147 | district: DISTRICTS[selectedDistrictId] 148 | }; 149 | }; 150 | 151 | export default connect( 152 | mapStateToProps 153 | )(withStyles(styles)(DistrictInfo)); 154 | -------------------------------------------------------------------------------- /frontend/src/feature/sidebar/district_info/LeadershipTab.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import District from '../../../shared/models/District'; 3 | import DISTRICT_LEADERSHIP from '../../../shared/data/districts-leadership.json'; 4 | import DistrictLeadership from '../../../shared/models/DistrictLeadership'; 5 | import { List, ListItem, ListItemText, WithStyles } from 'material-ui'; 6 | import withStyles from 'material-ui/styles/withStyles'; 7 | import { Theme } from 'material-ui/styles'; 8 | 9 | interface Props { 10 | district: District; 11 | } 12 | 13 | type ClassKey = 14 | | 'emptyListContainer'; 15 | 16 | type PropsWithStyles = Props & WithStyles; 17 | 18 | class LeadershipTab extends Component { 19 | render() { 20 | const { classes } = this.props; 21 | const leadership = DISTRICT_LEADERSHIP[this.props.district.id] as DistrictLeadership; 22 | if (!leadership) { 23 | return ( 24 |
25 |

Information currently unavailable, check website.

26 |
27 | ); 28 | } 29 | return ( 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | } 40 | } 41 | 42 | const styles = (theme: Theme) => ( 43 | { 44 | emptyListContainer: { 45 | textAlign: 'center' as 'center', 46 | paddingTop: theme.spacing.unit * 3, 47 | paddingBottom: theme.spacing.unit, 48 | } 49 | } 50 | ); 51 | 52 | export default withStyles(styles)(LeadershipTab); 53 | -------------------------------------------------------------------------------- /frontend/src/feature/sidebar/district_info/calendar/CalendarTab.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import _, { Dictionary } from 'lodash'; 3 | import { CircularProgress, List, ListItem, ListItemIcon, ListItemText, ListSubheader, WithStyles } from 'material-ui'; 4 | import { Theme } from 'material-ui/styles'; 5 | import withStyles from 'material-ui/styles/withStyles'; 6 | import moment, { Moment } from 'moment'; 7 | import District from '../../../../shared/models/District'; 8 | import { Calendar } from '../../../../shared/models/Calendar'; 9 | import PropTypes from 'prop-types'; 10 | import { SwipeableViewsChildContext } from '../../../../shared/types/swipeable-views'; 11 | import Divider from 'material-ui/Divider'; 12 | import EventIcon from 'material-ui-icons/Event'; 13 | import WarningIcon from 'material-ui-icons/Warning'; 14 | import { amber } from 'material-ui/colors'; 15 | import SubscribeDialog from './SubscribeDialog'; 16 | import Typography from 'material-ui/Typography'; 17 | import Grid from 'material-ui/Grid'; 18 | import EventDialog from './EventDialog'; 19 | 20 | interface Props { 21 | district: District; 22 | } 23 | 24 | type ClassKey = 25 | | 'calendarList' 26 | | 'calendarListItemText' 27 | | 'eventAddress' 28 | | 'eventDate' 29 | | 'eventDateDom' 30 | | 'eventDateDow' 31 | | 'eventsHeader' 32 | | 'emptyListContainer'; 33 | 34 | type PropsWithStyles = Props & WithStyles; 35 | 36 | interface State { 37 | events?: CalendarEvent[]; 38 | isSubscribeDialogOpen: boolean; 39 | calendar?: Calendar; 40 | calendarWebUrl?: string; 41 | selectedEvent?: CalendarEvent; 42 | } 43 | 44 | interface Context { 45 | swipeableViews: SwipeableViewsChildContext; 46 | } 47 | 48 | enum RenderEventsState { 49 | LOADING, 50 | HAS_EVENTS, 51 | NO_EVENTS 52 | } 53 | 54 | interface RenderData { 55 | isScraped: boolean; 56 | calendarWebUrl?: string; 57 | calendarIcalUrl?: string; 58 | eventsState: RenderEventsState; 59 | eventsByMonth?: Dictionary; 60 | } 61 | 62 | interface EventRenderData { 63 | event: CalendarEvent; 64 | groupKey: string; 65 | monthText: string; 66 | domText: string; 67 | dowText: string; 68 | primaryText: string; 69 | secondaryText: string; 70 | fragmentKey: string; 71 | listItemKey: string; 72 | isFirstForDay: boolean; 73 | } 74 | 75 | class CalendarTab extends Component { 76 | 77 | static contextTypes = { 78 | swipeableViews: PropTypes.object.isRequired, 79 | }; 80 | 81 | context: Context; 82 | 83 | constructor(props: PropsWithStyles) { 84 | super(props); 85 | this.state = { isSubscribeDialogOpen: false }; 86 | } 87 | 88 | componentDidMount() { 89 | this.fetchEvents(); 90 | } 91 | 92 | componentDidUpdate(prevProps: PropsWithStyles, prevState: State) { 93 | if (this.props.district !== prevProps.district) { 94 | this.fetchEvents(); 95 | } 96 | 97 | this.context.swipeableViews.slideUpdateHeight(); 98 | } 99 | 100 | render() { 101 | const { classes } = this.props; 102 | const { isSubscribeDialogOpen } = this.state; 103 | const data = this.createRenderData(); 104 | return ( 105 |
106 | { data.eventsState === RenderEventsState.LOADING && ( 107 |
108 | 109 |
110 | )} 111 | { data.eventsState === RenderEventsState.HAS_EVENTS && ( 112 | 113 | this.setState({ isSubscribeDialogOpen: true })} 116 | > 117 | 118 | 119 | 120 | 121 | 122 | 123 | {data.isScraped && ( 124 | 125 | 126 | 127 | 128 | 133 | 134 | )} 135 | {_(data.eventsByMonth) 136 | .map((monthEvents, month) => ( 137 | 138 | {month} 139 | {monthEvents.map((event: EventRenderData) => ( 140 | this.setState({ selectedEvent: event.event })} 144 | > 145 | 146 | 151 | 154 | {event.domText} 155 | 156 | 159 | {event.dowText} 160 | 161 | 162 | 163 | 171 | 172 | 173 | ) 174 | )} 175 | ) 176 | ) 177 | .value() 178 | } 179 | {this.state.selectedEvent && 180 | this.setState({selectedEvent: undefined})} 183 | /> 184 | } 185 | 186 | )} 187 | { data.eventsState === RenderEventsState.NO_EVENTS && ( 188 |
189 |

Events currently unavailable, 190 | check website.

191 |
192 | )} 193 | { isSubscribeDialogOpen && ( 194 | this.setState({ isSubscribeDialogOpen: false })} 197 | /> 198 | )} 199 |
200 | ); 201 | } 202 | 203 | private fetchEvents() { 204 | this.setState({events: undefined}); 205 | if (this.props.district.calendar) { 206 | const cal = new Calendar(this.props.district.calendar); 207 | cal.events 208 | .then((events) => { 209 | this.setState({ events, calendar: cal }); 210 | }) 211 | .catch((err) => { 212 | console.log('failed to get events', err); 213 | this.setState({ events: [] }); 214 | }); 215 | } else { 216 | this.setState({ events: [] }); 217 | } 218 | } 219 | 220 | private createRenderData(): RenderData { 221 | const { district } = this.props; 222 | const { events, calendar } = this.state; 223 | 224 | const getEventState = (): RenderEventsState => { 225 | if (!events) { 226 | return RenderEventsState.LOADING; 227 | } else if (events && events.length > 0) { 228 | return RenderEventsState.HAS_EVENTS; 229 | } else { 230 | return RenderEventsState.NO_EVENTS; 231 | } 232 | }; 233 | 234 | const getSecondaryText = (event: CalendarEvent, eventMoment: Moment): string => { 235 | const eventTime = eventMoment.format('h:mma'); 236 | if (event.location) { 237 | return `${eventTime} at ${event.location}`; 238 | } else { 239 | return eventTime; 240 | } 241 | }; 242 | 243 | const getIsFirstForDay = (list: ArrayLike, index: number): boolean => { 244 | if (index === 0) { 245 | return true; 246 | } 247 | const prevEvent = list[index - 1]; 248 | const thisEvent = list[index]; 249 | return !moment(prevEvent.date).isSame(moment(thisEvent.date), 'day'); 250 | }; 251 | 252 | return { 253 | isScraped: !!(district.calendar && district.calendar.scraperId), 254 | calendarWebUrl: district.calendar ? district.calendar.web : district.website, 255 | calendarIcalUrl: calendar && calendar.icalUrl, 256 | eventsState: getEventState(), 257 | eventsByMonth: events && _(events) 258 | .map((event, index, list) => { 259 | const eventMoment = moment(event.date); 260 | return { 261 | event: event, 262 | groupKey: eventMoment.format('MMMM YYYY'), 263 | domText: eventMoment.format('D'), 264 | dowText: eventMoment.format('ddd'), 265 | primaryText: event.summary, 266 | secondaryText: getSecondaryText(event, eventMoment), 267 | fragmentKey: `fragment-${event.id}`, 268 | listItemKey: `item-${event.id}`, 269 | isFirstForDay: getIsFirstForDay(list, index) 270 | } as EventRenderData; 271 | }) 272 | .groupBy((event) => event.groupKey) 273 | .value() 274 | }; 275 | } 276 | } 277 | 278 | const styles = (theme: Theme) => ( 279 | { 280 | calendarList: { 281 | overflow: 'hidden' as 'hidden', 282 | whiteSpace: 'nowrap' as 'nonowrap', 283 | }, 284 | calendarListItemText: { 285 | maskImage: 'linear-gradient(left, white 80%, rgba(255,255,255,0) 100%)' 286 | }, 287 | eventsHeader: { 288 | color: theme.palette.text.secondary 289 | }, 290 | eventAddress: { 291 | whiteSpace: 'nowrap' as 'nowrap' 292 | }, 293 | eventDate: { 294 | marginRight: theme.spacing.unit * 2 295 | }, 296 | eventDateDom: { 297 | fontSize: '1.2rem' 298 | }, 299 | eventDateDow: { 300 | fontSize: '0.8rem' 301 | }, 302 | emptyListContainer: { 303 | textAlign: 'center' as 'center', 304 | paddingTop: theme.spacing.unit * 3, 305 | paddingBottom: theme.spacing.unit, 306 | } 307 | } 308 | ); 309 | 310 | export default withStyles(styles)(CalendarTab); 311 | -------------------------------------------------------------------------------- /frontend/src/feature/sidebar/district_info/calendar/EventDialog.tsx: -------------------------------------------------------------------------------- 1 | import Dialog from 'material-ui/Dialog/Dialog'; 2 | import DialogTitle from 'material-ui/Dialog/DialogTitle'; 3 | import DialogContent from 'material-ui/Dialog/DialogContent'; 4 | import AccessTimeIcon from 'material-ui-icons/AccessTime'; 5 | import PlaceIcon from 'material-ui-icons/Place'; 6 | import React, { Component } from 'react'; 7 | import TextFieldsIcons from 'material-ui-icons/ShortText'; 8 | import Grid from 'material-ui/Grid/Grid'; 9 | import DialogActions from 'material-ui/Dialog/DialogActions'; 10 | import Button from 'material-ui/Button/Button'; 11 | import Html from '../../../../shared/components/Html'; 12 | import moment from 'moment'; 13 | import { Theme, WithStyles } from 'material-ui'; 14 | import withStyles from 'material-ui/styles/withStyles'; 15 | import { bind } from 'lodash-decorators/bind'; 16 | 17 | interface Props { 18 | event: CalendarEvent; 19 | onDialogExited: () => void; 20 | } 21 | 22 | interface State { 23 | isOpen: boolean; 24 | } 25 | 26 | type PropsWithStyles = Props & WithStyles<'eventDescription'>; 27 | 28 | class EventDialog extends Component { 29 | 30 | constructor(props: PropsWithStyles) { 31 | super(props); 32 | this.state = { isOpen: true }; 33 | } 34 | 35 | render(): React.ReactNode { 36 | const { event, classes } = this.props; 37 | return ( 38 | 43 | {event.summary} 44 | 45 | 46 | 47 | 48 | 49 | 50 | {moment(event.date).format('LLL')} 51 | 52 | {event.location && <> 53 | 54 | 55 | 56 | 57 | {event.location} 58 | 59 | } 60 | {event.description && <> 61 | 62 | 63 | 64 | 65 | 66 | 67 | } 68 | 69 | 70 | 71 | 72 | 73 | 74 | ); 75 | } 76 | 77 | @bind() 78 | private onDialogClose() { 79 | this.setState({ isOpen: false }); 80 | } 81 | } 82 | 83 | const styles = (theme: Theme) => ({ 84 | eventDescription: { 85 | whiteSpace: 'pre-line' as 'pre-line' 86 | } 87 | }); 88 | 89 | export default withStyles(styles)(EventDialog); 90 | -------------------------------------------------------------------------------- /frontend/src/feature/sidebar/district_info/calendar/SubscribeDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Component, default as React } from 'react'; 2 | import Dialog, { DialogActions, DialogTitle } from 'material-ui/Dialog'; 3 | import Button from 'material-ui/Button'; 4 | import { bind } from 'lodash-decorators/bind'; 5 | import { DialogContent, DialogContentText, InputAdornment } from 'material-ui'; 6 | import IconButton from 'material-ui/IconButton'; 7 | import ContentCopyIcon from 'material-ui-icons/ContentCopy'; 8 | import TextField from 'material-ui/TextField'; 9 | import SubscribeText from './subscribe-text.html'; 10 | import Html from '../../../../shared/components/Html'; 11 | 12 | interface Props { 13 | subscribeUrl: string; 14 | onDialogExited: () => void; 15 | } 16 | 17 | interface State { 18 | isOpen: boolean; 19 | } 20 | 21 | class SubscribeDialog extends Component { 22 | 23 | private inputEl: HTMLInputElement; 24 | 25 | constructor(props: Props) { 26 | super(props); 27 | this.state = { isOpen: true }; 28 | } 29 | 30 | render() { 31 | return ( 32 | 37 | Add to Calendar 38 | 39 | 40 | 41 | 42 | this.inputEl = el} 44 | fullWidth={true} 45 | value={this.props.subscribeUrl} 46 | inputProps={{ 47 | readOnly: true, 48 | }} 49 | InputProps={{ 50 | endAdornment: ( 51 | 52 | 53 | 54 | 55 | 56 | ) 57 | }} 58 | /> 59 | 60 | 61 | 62 | 63 | 64 | ); 65 | } 66 | 67 | @bind() 68 | private onCopyClicked() { 69 | if (this.inputEl) { 70 | this.inputEl.select(); 71 | document.execCommand('copy'); 72 | } 73 | } 74 | 75 | @bind() 76 | private onDialogClose() { 77 | this.setState({ isOpen: false }); 78 | } 79 | } 80 | 81 | export default SubscribeDialog; 82 | -------------------------------------------------------------------------------- /frontend/src/feature/sidebar/district_info/calendar/subscribe-text.html: -------------------------------------------------------------------------------- 1 | To have the latest meetings for this community board automatically appear in your calendar 2 | app, copy and paste the URL below into its subscription feature. See instructions for 3 | Google Calendar and 4 | iCloud. 5 | -------------------------------------------------------------------------------- /frontend/src/feature/sidebar/intro/Intro.tsx: -------------------------------------------------------------------------------- 1 | import { CardHeader, Menu, MenuItem, WithStyles } from 'material-ui'; 2 | import Card, { CardContent } from 'material-ui/Card'; 3 | import { Theme, withStyles } from 'material-ui/styles'; 4 | import React, { Component, MouseEvent } from 'react'; 5 | import IconButton from 'material-ui/IconButton'; 6 | import MoreVertIcon from 'material-ui-icons/MoreVert'; 7 | import { bind } from 'lodash-decorators/bind'; 8 | import { connect } from 'react-redux'; 9 | import { RootAction } from '../../../shared/actions'; 10 | import { Dispatch } from 'redux'; 11 | import { push } from 'react-router-redux'; 12 | import IntroHtml from './intro.md'; 13 | import Html from '../../../shared/components/Html'; 14 | 15 | interface DispatchProps { 16 | onShowAbout: () => void; 17 | onShowStatus: () => void; 18 | } 19 | 20 | type Props = DispatchProps; 21 | type PropsWithStyles = Props & WithStyles<'card'|'introText'>; 22 | 23 | interface State { 24 | menuAnchorEl?: HTMLElement; 25 | } 26 | 27 | class Intro extends Component { 28 | 29 | state = {} as State; 30 | 31 | render() { 32 | const { classes } = this.props; 33 | const { menuAnchorEl } = this.state; 34 | return ( 35 | 36 | 39 | 40 | } 41 | title="59Boards.nyc" 42 | /> 43 | 49 | Report Issue 50 | Calendar Status 51 | About 59Boards 52 | 53 | 54 | 55 | 56 | 57 | ); 58 | } 59 | 60 | @bind() 61 | private onShowMenuButtonClick(event: MouseEvent) { 62 | this.setState({ menuAnchorEl: event.currentTarget }); 63 | } 64 | 65 | @bind() 66 | private onReportIssueItemClick(event: MouseEvent) { 67 | window.open('https://github.com/codebutler/59boards/issues', '_blank'); 68 | } 69 | 70 | @bind() 71 | private onStatusItemClick(event: MouseEvent) { 72 | this.props.onShowStatus(); 73 | this.onMenuClose(); 74 | } 75 | 76 | @bind() 77 | private onAboutItemClick(event: MouseEvent) { 78 | this.props.onShowAbout(); 79 | this.onMenuClose(); 80 | } 81 | 82 | @bind() 83 | private onMenuClose() { 84 | this.setState({ menuAnchorEl: undefined }); 85 | } 86 | } 87 | 88 | const styles = (theme: Theme) => ({ 89 | card: { 90 | marginBottom: theme.spacing.unit 91 | }, 92 | introText: { 93 | extend: theme.typography.body1, 94 | '& p': { 95 | marginTop: 0 96 | }, 97 | '& ul': { 98 | marginBottom: 0 99 | }, 100 | '& ul li': { 101 | paddingBottom: theme.spacing.unit 102 | }, 103 | } 104 | }); 105 | 106 | const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => { 107 | return { 108 | onShowAbout: () => { 109 | dispatch(push('/about')); 110 | }, 111 | onShowStatus: () => { 112 | dispatch(push('/status')); 113 | } 114 | }; 115 | }; 116 | 117 | export default connect( 118 | null, 119 | mapDispatchToProps 120 | )(withStyles(styles)(Intro)); 121 | -------------------------------------------------------------------------------- /frontend/src/feature/sidebar/intro/intro.md: -------------------------------------------------------------------------------- 1 | New York City's government includes a system of 59 community board districts that offer a way for the public to get involved with local politics. Many New Yorkers don't know they exist, and the process to get involved can seem overwhelming. 2 | 3 | Most boards don't provide an easy way to get notified of upcoming meetings, making participation difficult. 4 | 59Boards [scrapes](https://en.wikipedia.org/wiki/Web_scraping) event information from board websites and provides a feed for your calendar app's subscription feature. 5 | 6 | Enter your address below or select a district on the map to get started! 7 | 8 | * [About Community Boards (nyc.gov)](http://www.nyc.gov/html/cau/html/cb/about.shtml) 9 | * [How to join your community board (Curbed)](https://ny.curbed.com/2017/3/15/14918194/nyc-community-board-member-get-involved) 10 | -------------------------------------------------------------------------------- /frontend/src/feature/sidebar/search/Search.tsx: -------------------------------------------------------------------------------- 1 | import flatten, { NestedArray } from 'array-flatten'; 2 | import match from 'autosuggest-highlight/match'; 3 | import parse from 'autosuggest-highlight/parse'; 4 | import MapboxClient from 'mapbox'; 5 | import { CircularProgress, WithStyles } from 'material-ui'; 6 | import MyLocationIcon from 'material-ui-icons/MyLocation'; 7 | import SearchIcon from 'material-ui-icons/Search'; 8 | import Card, { CardContent } from 'material-ui/Card'; 9 | import Grid from 'material-ui/Grid'; 10 | import IconButton from 'material-ui/IconButton'; 11 | import InputAdornment from 'material-ui/Input/InputAdornment'; 12 | import { MenuItem } from 'material-ui/Menu'; 13 | import Paper from 'material-ui/Paper'; 14 | import { Theme, withStyles } from 'material-ui/styles'; 15 | import TextField from 'material-ui/TextField'; 16 | import PromisedLocation from 'promised-location'; 17 | import PropTypes from 'prop-types'; 18 | import React, { Component, CSSProperties, FocusEvent, FormEvent } from 'react'; 19 | import Autosuggest, { 20 | ChangeEvent, 21 | InputProps, 22 | RenderSuggestionParams, 23 | RenderSuggestionsContainerParams, 24 | SuggestionSelectedEventData, 25 | SuggestionsFetchRequestedParams 26 | } from 'react-autosuggest'; 27 | import { connect } from 'react-redux'; 28 | import { Dispatch } from 'redux'; 29 | import { Response } from 'rest'; 30 | import { RootAction, selectLocation } from '../../../shared/actions'; 31 | import { AppContext } from '../../../app/App'; 32 | import { NYC_BOUNDING_BOX, NYC_CENTER } from '../../../shared/constants'; 33 | import { RootState } from '../../../shared/models/RootState'; 34 | import Location from '../../../shared/models/Location'; 35 | import { bind } from 'lodash-decorators'; 36 | import CarmenLocation = mapbox.CarmenLocation; 37 | 38 | interface OwnProps { 39 | onSearchFocusChanged: (isFocused: boolean) => void; 40 | } 41 | 42 | interface StateProps { 43 | selectedLocation?: Location; 44 | } 45 | 46 | interface DispatchProps { 47 | onLocationSelected: (location: Location) => void; 48 | } 49 | 50 | type Props = OwnProps & StateProps & DispatchProps; 51 | 52 | type ClassKey = 53 | | 'card' 54 | | 'container' 55 | | 'suggestionsContainerOpen' 56 | | 'suggestion' 57 | | 'suggestionsList' 58 | | 'cardContent' 59 | | 'root' 60 | | 'gridTextField' 61 | | 'myLocationAdornment' 62 | | 'myLocationIcon' 63 | | 'myLocationProgress'; 64 | 65 | type PropsWithStyles = Props & WithStyles; 66 | 67 | class Search extends Component { 68 | 69 | static contextTypes = { 70 | mapboxClient: PropTypes.instanceOf(MapboxClient).isRequired 71 | }; 72 | 73 | context: AppContext; 74 | 75 | state = { 76 | inputValue: '', 77 | location: null, 78 | suggestions: [], 79 | isWaitingForLocation: false 80 | }; 81 | 82 | private static getSuggestionValue(suggestion: Location) { 83 | return suggestion.place_name!; 84 | } 85 | 86 | private static renderSuggestionsContainer(options: RenderSuggestionsContainerParams) { 87 | const {containerProps, children} = options; 88 | return ( 89 | 90 | {children} 91 | 92 | ); 93 | } 94 | 95 | componentWillMount() { 96 | const selectedLocation = this.props.selectedLocation; 97 | if (selectedLocation) { 98 | this.setState({ inputValue: selectedLocation.place_name }); 99 | } 100 | } 101 | 102 | componentWillUnmount() { 103 | this.props.onSearchFocusChanged(false); 104 | } 105 | 106 | render() { 107 | const { classes } = this.props; 108 | const { inputValue } = this.state; 109 | return ( 110 | 132 | ); 133 | } 134 | 135 | @bind() 136 | private renderInput(inputProps: InputProps) { 137 | const { classes, ref, ...other } = inputProps; 138 | const {isWaitingForLocation} = this.state; 139 | return ( 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 160 | 161 |
162 | ) : ( 163 | 167 | 168 | 172 | 173 | 174 | ), 175 | disableUnderline: true, 176 | classes: { 177 | input: classes.input, 178 | }, 179 | ...other as object, 180 | }} 181 | /> 182 | 183 | 184 | 185 | 186 | ); 187 | } 188 | 189 | @bind() 190 | private onChange(event: React.FormEvent, { newValue }: ChangeEvent) { 191 | this.setState({ inputValue: newValue}); 192 | } 193 | 194 | @bind() 195 | private onSuggestionsFetchRequested({ value }: SuggestionsFetchRequestedParams) { 196 | this.context.mapboxClient.geocodeForward(value, { 197 | autocomplete: true, 198 | bbox: flatten(NYC_BOUNDING_BOX as NestedArray), 199 | proximity: NYC_CENTER 200 | }) 201 | .then((res: Response) => { 202 | const suggestions = res.entity.features 203 | .filter((feature: CarmenLocation) => feature.place_type.includes('address')); 204 | this.setState({ suggestions: suggestions }); 205 | }) 206 | .catch((err: object) => { 207 | console.error(err); 208 | this.setState({suggestions: []}); 209 | }); 210 | } 211 | 212 | @bind() 213 | private onSuggestionsClearRequested() { 214 | this.setState({ 215 | suggestions: [] 216 | }); 217 | } 218 | 219 | @bind() 220 | private onSuggestionSelected(event: FormEvent, { suggestion }: SuggestionSelectedEventData) { 221 | this.props.onLocationSelected(suggestion); 222 | } 223 | 224 | @bind() 225 | private onMyLocationClicked() { 226 | const locator = new PromisedLocation({ 227 | enableHighAccuracy: true, 228 | timeout: 10000, 229 | maximumAge: 60000 230 | }); 231 | 232 | this.setState({ 233 | isWaitingForLocation: true 234 | }); 235 | 236 | locator 237 | .then((position) => { 238 | this.props.onLocationSelected({ 239 | center: [position.coords.longitude, position.coords.latitude] 240 | }); 241 | }) 242 | .catch((err) => console.error('failed to get location', err)) 243 | .finally(() => { 244 | this.setState({ 245 | isWaitingForLocation: false 246 | }); 247 | }); 248 | } 249 | 250 | @bind() 251 | private onFocus() { 252 | this.props.onSearchFocusChanged(true); 253 | } 254 | 255 | @bind() 256 | private onBlur(event: FocusEvent) { 257 | setTimeout(() => { this.props.onSearchFocusChanged(false); }, 0); 258 | } 259 | 260 | private renderSuggestion(suggestion: Location, { query, isHighlighted }: RenderSuggestionParams) { 261 | const text = suggestion.place_name!; 262 | 263 | const matches = match(text, query); 264 | const parts = parse(text, matches); 265 | 266 | return ( 267 | 268 |
269 | {parts.map((part, index) => { 270 | const style = part.highlight ? { fontWeight: 600 } as CSSProperties : {}; 271 | return ({part.text}); 272 | })} 273 |
274 |
275 | ); 276 | } 277 | } 278 | 279 | const styles = (theme: Theme) => ({ 280 | card: { }, 281 | container: { }, 282 | suggestionsContainerOpen: { 283 | left: 0, 284 | right: 0, 285 | marginTop: 0, 286 | marginBottom: 0 287 | }, 288 | suggestion: { 289 | display: 'block', 290 | }, 291 | suggestionsList: { 292 | margin: 0, 293 | padding: 0, 294 | listStyleType: 'none', 295 | }, 296 | cardContent: { 297 | padding: 0, 298 | '&:last-child': { 299 | paddingBottom: 0, 300 | } 301 | }, 302 | root: { 303 | flexGrow: 1 304 | }, 305 | gridTextField: { 306 | flexGrow: 1 307 | }, 308 | myLocationAdornment: { 309 | maxHeight: 'inherit' 310 | }, 311 | myLocationIcon: { 312 | width: 18, 313 | height: 18, 314 | opacity: 0.8 315 | }, 316 | myLocationProgress: { 317 | display: 'flex', 318 | justifyContent: 'center' as 'center', 319 | alignItems: 'center' as 'center', 320 | width: 48, 321 | height: 48 322 | } 323 | }); 324 | 325 | const mapStateToProps = (state: RootState): StateProps => { 326 | return { 327 | selectedLocation: state.selectedLocation 328 | }; 329 | }; 330 | 331 | const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => { 332 | return { 333 | onLocationSelected: (location: Location) => { 334 | dispatch(selectLocation(location)); 335 | } 336 | }; 337 | }; 338 | 339 | export default connect( 340 | mapStateToProps, 341 | mapDispatchToProps 342 | )(withStyles(styles)(Search)); 343 | -------------------------------------------------------------------------------- /frontend/src/feature/sidebar/status/Status.tsx: -------------------------------------------------------------------------------- 1 | import { Component, default as React } from 'react'; 2 | import { CardContent, CardHeader, GridListTile, Theme, WithStyles } from 'material-ui'; 3 | import IconButton from 'material-ui/IconButton'; 4 | import Card from 'material-ui/Card'; 5 | import CloseIcon from 'material-ui-icons/Close'; 6 | import { RootAction } from '../../../shared/actions'; 7 | import { Dispatch } from 'redux'; 8 | import { connect } from 'react-redux'; 9 | import { push } from 'react-router-redux'; 10 | import DISTRICTS from '../../../shared/data/districts-info.json'; 11 | import District from '../../../shared/models/District'; 12 | import _ from 'lodash'; 13 | import red from 'material-ui/colors/red'; 14 | import green from 'material-ui/colors/green'; 15 | import orange from 'material-ui/colors/orange'; 16 | import withStyles, { ClassNameMap } from 'material-ui/styles/withStyles'; 17 | import GridList from 'material-ui/GridList'; 18 | 19 | interface DispatchProps { 20 | onCloseClicked: () => void; 21 | } 22 | 23 | type ClassKey = 24 | | 'cardContentRoot' 25 | | 'tileBad' 26 | | 'tileGood' 27 | | 'tileMediocre'; 28 | 29 | type Props = DispatchProps; 30 | type PropsWithStyles = Props & WithStyles; 31 | 32 | class Status extends Component { 33 | 34 | private static classForTile(district: District, classes: ClassNameMap): string { 35 | const isGood = !!(district.calendar 36 | && (district.calendar.scraperId || district.calendar.googleCalendarId)); 37 | const isScraped = district.calendar && district.calendar.scraperId; 38 | return isGood ? (isScraped ? classes.tileMediocre : classes.tileGood) : classes.tileBad; 39 | } 40 | 41 | render() { 42 | const { classes } = this.props; 43 | return ( 44 | 45 | 48 | 49 | 50 | )} 51 | title="Calendar Status" 52 | /> 53 | 54 | 58 | { 59 | _.toPairs(DISTRICTS).map(([key, district]: [string, District]) => ( 60 | alert(JSON.stringify(district, null, ' '))} 64 | > 65 | {`${district.borough} CB${district.number}`} 66 | 67 | )) 68 | } 69 | 70 | 71 | 72 | ); 73 | } 74 | } 75 | 76 | const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => { 77 | return { 78 | onCloseClicked: () => { 79 | dispatch(push('/')); 80 | } 81 | }; 82 | }; 83 | 84 | const tileStyle = { 85 | display: 'flex', 86 | justifyContent: 'center' as 'center', 87 | alignItems: 'center' as 'center', 88 | cursor: 'pointer', 89 | }; 90 | 91 | const styles = (theme: Theme) => ({ 92 | cardContentRoot: { 93 | paddingTop: 0 94 | }, 95 | tileGood: { 96 | extend: tileStyle, 97 | 98 | backgroundColor: green[300], 99 | '&:hover': { 100 | backgroundColor: green[400], 101 | } 102 | }, 103 | tileBad: { 104 | extend: tileStyle, 105 | 106 | backgroundColor: red[300], 107 | '&:hover': { 108 | backgroundColor: red[400], 109 | } 110 | }, 111 | tileMediocre: { 112 | extend: tileStyle, 113 | 114 | backgroundColor: orange[300], 115 | '&:hover': { 116 | backgroundColor: orange[400], 117 | } 118 | } 119 | }); 120 | 121 | export default connect(null, mapDispatchToProps)(withStyles(styles)(Status)); 122 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | html { 2 | overflow: hidden; 3 | height: 100%; 4 | } 5 | 6 | body { 7 | height: 100%; 8 | overflow: auto; 9 | margin: 0; 10 | padding: 0; 11 | font-family: sans-serif; 12 | } 13 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | import { applyMiddleware, createStore } from 'redux'; 5 | import { createLogger } from 'redux-logger'; 6 | import thunk from 'redux-thunk'; 7 | import 'typeface-roboto'; 8 | import App from './app/App'; 9 | import './index.css'; 10 | import reducer from './shared/reducers'; 11 | import registerServiceWorker from './app/registerServiceWorker'; 12 | import { ConnectedRouter, routerMiddleware } from 'react-router-redux'; 13 | import { composeWithDevTools } from 'redux-devtools-extension'; 14 | import createBrowserHistory from 'history/createBrowserHistory'; 15 | import './shared/polyfills'; 16 | import { googleAnalytics } from './shared/reactGAMiddlewares'; 17 | import Raven from 'raven-js'; 18 | 19 | Raven.config('https://e2202585cda74a63ae066e88f237596d@sentry.io/302432') 20 | .install(); 21 | 22 | const LOGGING = false; 23 | const CACHING = false; 24 | 25 | const history = createBrowserHistory(); 26 | 27 | const middlewares = []; 28 | middlewares.push(thunk); 29 | middlewares.push(routerMiddleware(history)); 30 | middlewares.push(googleAnalytics); 31 | if (LOGGING) { 32 | middlewares.push(createLogger({ 33 | diff: true 34 | })); 35 | } 36 | 37 | const store = createStore( 38 | reducer, composeWithDevTools( 39 | applyMiddleware(...middlewares) 40 | )); 41 | 42 | ReactDOM.render( 43 | 44 | 45 | 46 | 47 | , 48 | document.getElementById('root') 49 | ); 50 | 51 | if (CACHING) { 52 | registerServiceWorker(); 53 | } 54 | -------------------------------------------------------------------------------- /frontend/src/shared/actions/index.ts: -------------------------------------------------------------------------------- 1 | import Location from '../models/Location'; 2 | import { push } from 'react-router-redux'; 3 | import { Dispatch } from 'redux'; 4 | import { ThunkAction } from 'redux-thunk'; 5 | import booleanPointInPolygon from '@turf/boolean-point-in-polygon'; 6 | import { Feature, FeatureCollection, GeoJsonProperties, Polygon } from 'geojson'; 7 | import DISTRICTS_GEOJSON from '../../shared/data/districts-geo.json'; 8 | 9 | export enum ActionType { 10 | SELECT_LOCATION = 'SELECT_LOCATION', 11 | COMPONENT_RESIZED = 'COMPONENT_RESIZED', 12 | } 13 | 14 | interface SelectLocationAction { 15 | type: ActionType.SELECT_LOCATION; 16 | location: Location | null; 17 | } 18 | 19 | interface ComponentResizedAction { 20 | type: ActionType.COMPONENT_RESIZED; 21 | id: string; 22 | size: Size; 23 | } 24 | 25 | export type RootAction = 26 | | SelectLocationAction 27 | | ComponentResizedAction; 28 | 29 | export const selectDistrict = (districtId: number) => { 30 | return push('/districts/' + districtId); 31 | }; 32 | 33 | export const selectLocation = (location: Location | null): ThunkAction => { 34 | return (dispatch: Dispatch) => { 35 | dispatch({ 36 | type: ActionType.SELECT_LOCATION, 37 | location: location 38 | }); 39 | if (location !== null) { 40 | const featureCollection = DISTRICTS_GEOJSON as FeatureCollection; 41 | const districtFeature = featureCollection.features 42 | .find((feature: Feature) => { 43 | return booleanPointInPolygon(location.center, feature.geometry!); 44 | }); 45 | const districtId = (districtFeature && districtFeature.properties) 46 | ? districtFeature.properties.BoroCD : null; 47 | dispatch(selectDistrict(districtId)); 48 | } 49 | }; 50 | }; 51 | 52 | export const clearSelection = () => { 53 | return (dispatch: Dispatch) => { 54 | dispatch(push('/')); 55 | dispatch(selectLocation(null)); 56 | }; 57 | }; 58 | 59 | export const componentResized = (id: string, size: Size): ComponentResizedAction => { 60 | return { 61 | type: ActionType.COMPONENT_RESIZED, 62 | id: id, 63 | size: size 64 | }; 65 | }; 66 | -------------------------------------------------------------------------------- /frontend/src/shared/components/Html.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Theme, WithStyles } from 'material-ui'; 3 | import withStyles from 'material-ui/styles/withStyles'; 4 | 5 | interface Props { 6 | html: string; 7 | } 8 | 9 | type ClassKey = 10 | | 'root'; 11 | 12 | type PropsWithStyles = Props & WithStyles; 13 | 14 | class Html extends Component { 15 | render() { 16 | const { classes } = this.props; 17 | return ( 18 | 22 | ); 23 | } 24 | } 25 | 26 | const styles = (theme: Theme) => ({ 27 | root: { 28 | '& ul': { 29 | paddingLeft: theme.spacing.unit * 2, 30 | } 31 | } 32 | }); 33 | 34 | export default withStyles(styles)(Html); 35 | -------------------------------------------------------------------------------- /frontend/src/shared/constants.js: -------------------------------------------------------------------------------- 1 | exports = module.exports = { 2 | 'MAPBOX_TOKEN': 'pk.eyJ1IjoiY29kZWJ1dGxlciIsImEiOiJjajhvdnI5NHkwOXppMndubnoyYjdhZHN2In0.evH8Wvs-5rlT29mOaWEoCA', 3 | 4 | /* 5 | const dummyBoroCDs = this.map.querySourceFeatures("districts") 6 | .map((feature) => feature.properties.BoroCD) 7 | .filter((boroCD) => !DISTRICTS[boroCD] ); 8 | */ 9 | 'DUMMY_BORO_IDS': [481, 484, 482, 227, 355, 483, 480, 164, 226, 228, 356, 481, 484, 482, 227, 355, 483, 480, 164, 226, 595, 228, 356], 10 | 'NYC_BOUNDING_BOX': [[-74.2555928790469, 40.49612360054], [-73.7000104159344, 40.915541075395]], 11 | 'NYC_CENTER': { latitude: 40.7128, longitude: -74.0060 }, 12 | }; -------------------------------------------------------------------------------- /frontend/src/shared/data/districts-info.json: -------------------------------------------------------------------------------- 1 | { 2 | "101": { 3 | "id": 101, 4 | "number": 1, 5 | "neighborhoods": "Tribeca, Seaport/Civic Center, Financial District, Battery Park City", 6 | "address": "49-51 Chambers Street, Room 715\nNew York, NY 10007", 7 | "phone": "212-442-5050", 8 | "fax": "212-442-5055", 9 | "email": "man01@cb.nyc.gov", 10 | "website": "http://www.nyc.gov/html/mancb1/html/home/home.shtml", 11 | "twitter": "CommunityBoard1", 12 | "borough": "Manhattan" 13 | }, 14 | "102": { 15 | "id": 102, 16 | "number": 2, 17 | "neighborhoods": "Greenwich Village, West Village, NoHo, SoHo, Lower East Side, Chinatown, Little Italy", 18 | "address": "3 Washington Square Village, #1A\nNew York, NY 10012", 19 | "phone": "212-979-2272", 20 | "fax": "212-254-5102", 21 | "email": "cb2manhattan@nyc.rr.com", 22 | "website": "http://www.nyc.gov/manhattancb2", 23 | "borough": "Manhattan", 24 | "calendar": { 25 | "web": "http://www.nyc.gov/html/mancb2/html/calendar/calendar_js.shtml", 26 | "googleCalendarId": "communitybd2m@gmail.com" 27 | } 28 | }, 29 | "103": { 30 | "id": 103, 31 | "number": 3, 32 | "neighborhoods": "Tompkins Square, East Village, Lower East Side, Chinatown, Two Bridges", 33 | "address": "59 East 4th Street,\nNew York, NY 10003", 34 | "phone": "212-533-5300", 35 | "fax": "212-533-3659", 36 | "email": "info@cb3manhattan.org", 37 | "website": "http://www.nyc.gov/manhattancb3", 38 | "twitter": "CB3Man", 39 | "borough": "Manhattan", 40 | "calendar": { 41 | "web": "http://www.nyc.gov/html/mancb3/html/calendar/calendar.shtml" 42 | } 43 | }, 44 | "104": { 45 | "id": 104, 46 | "number": 4, 47 | "neighborhoods": "Clinton, Chelsea", 48 | "address": "330 West 42nd Street, Suite 2618\nNew York, NY 10036", 49 | "phone": "212-736-4536", 50 | "fax": "212-947-9512", 51 | "email": "info@manhattancb4.org", 52 | "website": "http://www.nyc.gov/mcb4", 53 | "twitter": "ManhattanBoard4", 54 | "borough": "Manhattan", 55 | "calendar": { 56 | "web": "http://www.nyc.gov/html/mancb4/html/calendar/calendarnew.shtml" 57 | } 58 | }, 59 | "105": { 60 | "id": 105, 61 | "number": 5, 62 | "neighborhoods": "Midtown", 63 | "address": "450 7th Avenue, Rm. 2109\nNew York, NY 10123", 64 | "phone": "212-465-0907", 65 | "fax": "212-465-1628", 66 | "email": "office@cb5.org", 67 | "website": "http://www.cb5.org", 68 | "twitter": "manhattancb5", 69 | "borough": "Manhattan", 70 | "calendar": { 71 | "web": "https://www.cb5.org/cb5/calendar/", 72 | "scraperId": "manhattan-cb5" 73 | } 74 | }, 75 | "106": { 76 | "id": 106, 77 | "number": 6, 78 | "neighborhoods": "Stuyvesant Town, Tudor City, Turtle Bay, Peter Cooper Village, Murray Hill, Gramercy Park, Kips Bay, Sutton Place", 79 | "address": "PO Box 1672\nNew York, NY 10159", 80 | "phone": "212-319-3750", 81 | "fax": "212-319-3772", 82 | "email": "office@cbsix.org", 83 | "website": "http://cbsix.org", 84 | "twitter": "cbsix", 85 | "borough": "Manhattan", 86 | "calendar": { 87 | "web": "http://cbsix.org/calendar/", 88 | "googleCalendarId": "no646j6t8mv9p5n4ep59407ut4@group.calendar.google.com" 89 | } 90 | }, 91 | "107": { 92 | "id": 107, 93 | "number": 7, 94 | "neighborhoods": "Manhattan Valley, Upper West Side, and Lincoln Square", 95 | "address": "250 W. 87th Street\nNew York, NY 10024", 96 | "phone": "212-362-4008", 97 | "fax": "212-595-9317", 98 | "email": "office@cb7.org", 99 | "website": "http://www.nyc.gov/mcb7", 100 | "twitter": "CB7Manhattan", 101 | "borough": "Manhattan", 102 | "calendar": { 103 | "web": "http://www1.nyc.gov/site/manhattancb7/meetings/calendar.page", 104 | "scraperId": "manhattan-cb7" 105 | } 106 | }, 107 | "108": { 108 | "id": 108, 109 | "number": 8, 110 | "neighborhoods": "Upper East Side, LenoxHill, Yorkville, and Roosevelt Island", 111 | "address": "505 Park Avenue Suite 620\nNew York, NY 10022", 112 | "phone": "212-758-4340", 113 | "fax": "212-758-4616", 114 | "email": "info@cb8m.com", 115 | "website": "http://www.cb8m.com/", 116 | "twitter": "cb8m", 117 | "borough": "Manhattan", 118 | "calendar": { 119 | "web": "https://www.cb8m.com/calendar/", 120 | "ical": "https://www.cb8m.com/calendar/?ical=1&tribe_display=month", 121 | "googleCalendarId": "924sjr4ftqvsj8hp8i68qcaqq4vpgmll@import.calendar.google.com" 122 | } 123 | }, 124 | "109": { 125 | "id": 109, 126 | "number": 9, 127 | "neighborhoods": "Hamilton Heights, Manhattanville, Morningside Heights, and West Harlem", 128 | "address": "16-18 Old Broadway\nNew York, NY 10027", 129 | "phone": "212-864-6200", 130 | "fax": "212-662-7396", 131 | "email": "nyc-cb9m@juno.com", 132 | "website": "http://www.cb9m.org", 133 | "twitter": "cb9manhattan", 134 | "borough": "Manhattan", 135 | "calendar": { 136 | "web": "http://www.cb9m.org/cal_events", 137 | "googleCalendarId": "g4b54u7hbpp1b6p63gp0n97448@group.calendar.google.com" 138 | } 139 | }, 140 | "110": { 141 | "id": 110, 142 | "number": 10, 143 | "neighborhoods": "Central Harlem", 144 | "address": "215 West 125th Street\n4th", 145 | "phone": "212.749.3105", 146 | "fax": "212.662.4215", 147 | "email": "mn10@cb.nyc.gov", 148 | "website": "http://www.nyc.gov/html/mancb10/html/home/home.shtml", 149 | "twitter": "mancb10", 150 | "borough": "Manhattan", 151 | "calendar": { 152 | "web": "http://www.nyc.gov/html/mancb10/html/calendar/calendar.shtml" 153 | } 154 | }, 155 | "111": { 156 | "id": 111, 157 | "number": 11, 158 | "neighborhoods": "East Harlem", 159 | "address": "1664 Park Avenue, Ground floor\nNew York, NY 10035", 160 | "phone": "212-831-8929", 161 | "fax": "212-369-3571", 162 | "email": "mn11@cb.nyc.gov", 163 | "website": "http://www.cb11m.org/", 164 | "twitter": "manhattancb11", 165 | "borough": "Manhattan", 166 | "calendar": { 167 | "web": "http://www.cb11m.org/pm-calendar/", 168 | "ical": "http://www.cb11m.org/?rhc_action=get_icalendar_events&post_type[]=events&calendar=meetings", 169 | "googleCalendarId": "861vgismc70jd1ns85rftjs0s27dt1da@import.calendar.google.com" 170 | } 171 | }, 172 | "112": { 173 | "id": 112, 174 | "number": 12, 175 | "neighborhoods": "Inwood and Washington Heights", 176 | "address": "530 West 166th Street, 6th", 177 | "phone": "212-568-8500", 178 | "fax": "212-740-8197", 179 | "email": "", 180 | "website": "http://www.nyc.gov/html/mancb12/html/home/home.shtml", 181 | "twitter": "mancb12", 182 | "borough": "Manhattan", 183 | "calendar": { 184 | "web": "http://www.nyc.gov/html/mancb12/html/meetings/meetings.shtml" 185 | } 186 | }, 187 | "201": { 188 | "id": 201, 189 | "number": 1, 190 | "neighborhoods": "Mott Haven, Port Morris, Melrose", 191 | "address": "3024 Third Avenue\nBronx, NY 10455", 192 | "phone": "718-585-7117", 193 | "fax": "718-292-0558", 194 | "email": "brxcb1@optonline.net", 195 | "website": "http://www.nyc.gov/html/bxcb1/html/home/home.shtml", 196 | "borough": "Bronx", 197 | "calendar": { 198 | "web": "http://www1.nyc.gov/site/bronxcb1/calendar/calendar.page" 199 | } 200 | }, 201 | "202": { 202 | "id": 202, 203 | "number": 2, 204 | "neighborhoods": "Hunts Point, Longwood, Morrisania", 205 | "address": "1029 East 163rd Street, Rm. 202\nBronx, New York 10459", 206 | "phone": "718-328-9125", 207 | "fax": "718-991-4974", 208 | "email": "brxcb2@optonline.net", 209 | "website": "http://bxcb2.org/", 210 | "twitter": "BxCB2", 211 | "borough": "Bronx", 212 | "calendar": { 213 | "web": "http://bxcb2.org/events/", 214 | "ical": "http://bxcb2.org/events/?ical=1&tribe_display=month", 215 | "scraperId": "bronx-cb2" 216 | } 217 | }, 218 | "203": { 219 | "id": 203, 220 | "number": 3, 221 | "neighborhoods": "Crotona Park, Claremont Village, Concourse Village, Woodstock, and Morrisania", 222 | "address": "1426 Boston Road,\nBronx, NY 10456", 223 | "phone": "718-378-8054", 224 | "fax": "718-378-8188", 225 | "email": "Brxcomm3@optonline.net", 226 | "website": "http://www.nyc.gov/html/bxcb3/html/home/home.shtml", 227 | "borough": "Bronx", 228 | "calendar": { 229 | "web": "http://www.nyc.gov/html/bxcb3/html/calendar/calendar.shtml" 230 | } 231 | }, 232 | "204": { 233 | "id": 204, 234 | "number": 4, 235 | "neighborhoods": "Highbridge, Concourse, Mount Eden, and Concourse Village", 236 | "address": "1650 Selwyn Avenue, #11A\nBronx, NY 10457", 237 | "phone": "718-299-0800", 238 | "fax": "718-294-7870", 239 | "email": "bx04@cb.nyc.gov", 240 | "website": "http://www.nyc.gov/html/bxcb4/html/home/home.shtml", 241 | "twitter": "bronxcb4", 242 | "borough": "Bronx", 243 | "calendar": { 244 | "web": "http://www1.nyc.gov/site/bronxcb4/calendar/calendar.page" 245 | } 246 | }, 247 | "205": { 248 | "id": 205, 249 | "number": 5, 250 | "neighborhoods": "Fordham, University Heights, Morris Heights, Bathgate, and Mount Hope", 251 | "address": "BCC Campus, McCracken Hall, Room 12/13\nW. 181st Street at Dr. Martin Luther King, Jr. Blvd.\nBronx, NY 10453", 252 | "phone": "718-364-2030", 253 | "fax": "718-220-1767", 254 | "email": "brxcb5@optonline.net", 255 | "website": "http://www.nyc.gov/html/bxcb5/html/home/home.shtml", 256 | "borough": "Bronx", 257 | "calendar": { 258 | "web": "http://www1.nyc.gov/site/bronxcb5/calendar/calendar.page", 259 | "scraperId": "bronx-cb5" 260 | } 261 | }, 262 | "206": { 263 | "id": 206, 264 | "number": 6, 265 | "neighborhoods": "Belmont, Bathgate, West Farms, East Tremont, and Bronx Park South", 266 | "address": "1932 Arthur Avenue, Rm. 709\nBronx, NY 10457", 267 | "phone": "718-579-6990", 268 | "fax": "718-579-6875", 269 | "email": "brxcb6@optonline.net", 270 | "website": "http://bronxcb6.org/", 271 | "twitter": "bronxcb6", 272 | "borough": "Bronx", 273 | "calendar": { 274 | "web": "http://bronxcb6.org/index.php/events-2/" 275 | } 276 | }, 277 | "207": { 278 | "id": 207, 279 | "number": 7, 280 | "neighborhoods": "Norwood, University Heights, Jerome Park, Bedford Park, Fordham, and Kingsbridge Heights", 281 | "address": "229-A East 204th Street\nBronx, NY 10458", 282 | "phone": "718-933-5650", 283 | "fax": "718-933-1829", 284 | "email": "info@BronxCB7.info", 285 | "website": "http://www.bronxcb7.info/", 286 | "twitter": "bronxcb7", 287 | "borough": "Bronx", 288 | "calendar": { 289 | "web": "http://www.bronxcb7.info/calendar/", 290 | "ical": "http://www.bronxcb7.info/?plugin=all-in-one-event-calendar&controller=ai1ec_exporter_controller&action=export_events&no_html=true", 291 | "googleCalendarId": "5sk15jnh5mqv82kokulj1pir9l8im1nv@import.calendar.google.com" 292 | } 293 | }, 294 | "208": { 295 | "id": 208, 296 | "number": 8, 297 | "neighborhoods": "Fieldston, Kingsbridge, Kingsbridge Heights, Marble Hill, Riverdale, Spuyten Duyvil, Van Cortlandt Village", 298 | "address": "5676 Riverdale Avenue,\nBronx, NY 10471", 299 | "phone": "718-884-3959", 300 | "fax": "718-796-2763", 301 | "email": "bx08@cb.nyc.gov", 302 | "website": "http://www.nyc.gov/bronxcb8", 303 | "twitter": "bxcb8", 304 | "borough": "Bronx", 305 | "calendar": { 306 | "web": "http://www.nyc.gov/html/bxcb8/html/calendar/calendar.shtml" 307 | } 308 | }, 309 | "209": { 310 | "id": 209, 311 | "number": 9, 312 | "neighborhoods": "Parkchester, Unionport, Soundview, Castle Hill, Bruckner, Harding Park, Bronx River and Clason Point", 313 | "address": "1967 Turnbull Avenue, Rm. 7\nBronx, NY 10473", 314 | "phone": "718-823-3034", 315 | "fax": "718-823-6461", 316 | "email": "bxbrd09@optonline.net", 317 | "website": "http://www1.nyc.gov/site/bronxcb9/index.page", 318 | "twitter": "BronxCB9", 319 | "borough": "Bronx", 320 | "calendar": { 321 | "web": "http://www1.nyc.gov/site/bronxcb9/calendar/calendar.page", 322 | "scraperId": "bronx-cb9" 323 | } 324 | }, 325 | "210": { 326 | "id": 210, 327 | "number": 10, 328 | "neighborhoods": "Co-op City, City Island, Spencer Estates, Throggs Neck, Country Club, Zerega, Westchester Square, Pelham Bay, Eastchester Bay, Schuylerville, Edgewater, Locust Point, and Silver Beach", 329 | "address": "3165 East Tremont Avenue,\nBronx, NY 10461", 330 | "phone": "718-892-1161", 331 | "fax": "718-863-6860", 332 | "email": "bx10@cb.nyc.gov", 333 | "website": "http://www1.nyc.gov/site/bronxcb10/index.page", 334 | "borough": "Bronx", 335 | "calendar": { 336 | "web": "http://www1.nyc.gov/site/bronxcb10/calendar/calendar.page" 337 | } 338 | }, 339 | "211": { 340 | "id": 211, 341 | "number": 11, 342 | "neighborhoods": "Allerton, Bronx Park East, Eastchester Gardens, Indian Village, Morris Park, Olinville, Parkside, Pelham Gardens, Pelham Parkway, Van Nest, and Westchester Heights", 343 | "address": "1741 Colden Avenue,\nBronx, NY 10462", 344 | "phone": "718-892-6262", 345 | "fax": "", 346 | "email": "", 347 | "borough": "Bronx", 348 | "website": "http://www.nyc.gov/bxcb11", 349 | "calendar": { 350 | "web": "http://www1.nyc.gov/site/bronxcb11/meetings/calendar.page" 351 | } 352 | }, 353 | "212": { 354 | "id": 212, 355 | "number": 12, 356 | "neighborhoods": "Edenwald, Wakefield, Williamsbridge, Woodlawn, Fish Bay, Eastchester, Olinville, and Baychester", 357 | "address": "4101 White Plains Rd\nBronx, New York 10466", 358 | "phone": "718-881-4455", 359 | "fax": "718-231-0635", 360 | "email": "gtorres@cb.nyc.gov", 361 | "website": "http://www.nyc.gov/bronxcb12", 362 | "borough": "Bronx", 363 | "calendar": { 364 | "web": "http://www1.nyc.gov/site/bronxcb12/calendar/calendar.page" 365 | } 366 | }, 367 | "301": { 368 | "id": 301, 369 | "number": 1, 370 | "neighborhoods": "Flushing Avenue, Williamsburg, Greenpoint, Northside, and Southside", 371 | "address": "435 Graham Avenue,\nBrooklyn, NY 11211", 372 | "phone": "718-389-0009", 373 | "fax": "718-389-0098", 374 | "email": "bk01@cb.nyc.gov", 375 | "website": "http://www.nyc.gov/brooklyncb1", 376 | "borough": "Brooklyn", 377 | "calendar": { 378 | "web": "http://www.nyc.gov/html/bkncb1/html/calendar/calendar.shtml", 379 | "scraperId": "brooklyn-cb1" 380 | } 381 | }, 382 | "302": { 383 | "id": 302, 384 | "number": 2, 385 | "neighborhoods": "Brooklyn Heights, Fulton Mall, Boerum Hill, Fort Greene, Brooklyn Navy Yard, Fulton Ferry, and Clinton Hill", 386 | "address": "350 Jay Street, 8th", 387 | "phone": "718-596-5410", 388 | "fax": "718-852-1461", 389 | "email": "cb2k@nyc.rr.com", 390 | "website": "http://www.nyc.gov/html/bkncb2/html/home/home.shtml", 391 | "borough": "Brooklyn", 392 | "calendar": { 393 | "web": "http://www.nyc.gov/html/bkncb2/html/calendar/calendar.shtml", 394 | "scraperId": "brooklyn-cb2" 395 | } 396 | }, 397 | "303": { 398 | "id": 303, 399 | "number": 3, 400 | "neighborhoods": "Bedford-Stuyvesant, Stuyvesant Heights, and Ocean Hill", 401 | "address": "1360 Fulton Street, Brooklyn, NY 11216", 402 | "phone": "718-622-6601", 403 | "fax": "718-857-5774", 404 | "email": "bk03@cb.nyc.gov", 405 | "website": "http://cb3brooklyn.org", 406 | "borough": "Brooklyn", 407 | "calendar": { 408 | "web": "http://www1.nyc.gov/site/brooklyncb3/meetings/calendar.page", 409 | "scraperId": "brooklyn-cb3" 410 | } 411 | }, 412 | "304": { 413 | "id": 304, 414 | "number": 4, 415 | "neighborhoods": "Bushwick", 416 | "address": "1420 Bushwick Avenue, Suite 370\nBrooklyn, NY 11207-1422", 417 | "phone": "718-628-8400", 418 | "fax": "718-628-8619", 419 | "email": "bk04@cb.nyc.gov", 420 | "website": "http://www.nyc.gov/brooklyncb4", 421 | "borough": "Brooklyn", 422 | "calendar": { 423 | "web": "http://www1.nyc.gov/site/brooklyncb4/calendar/calendar.page" 424 | } 425 | }, 426 | "305": { 427 | "id": 305, 428 | "number": 5, 429 | "neighborhoods": "East New York, Cypress Hills, Highland Park, New Lots, City Line, Starrett City, and Ridgewood", 430 | "address": "404 Pine Street, 3rd Floor\nBrooklyn, New York 11208", 431 | "phone": "929-221-8261", 432 | "fax": "718-827-7374", 433 | "email": "BK05@cb.nyc.gov", 434 | "website": "http://www.brooklyncb5.org", 435 | "borough": "Brooklyn", 436 | "calendar": { 437 | "web": "http://www.brooklyncb5.org/calendar/", 438 | "googleCalendarId": "bkcb5cc@gmail.com" 439 | } 440 | }, 441 | "306": { 442 | "id": 306, 443 | "number": 6, 444 | "neighborhoods": "Red Hook, Carroll Gardens, Park Slope, Gowanus, and Cobble Hill", 445 | "address": "250 Baltic Street,\nBrooklyn, NY 11201", 446 | "phone": "718-643-3027", 447 | "fax": "718-624-8410", 448 | "email": "info@brooklyncb6.org", 449 | "website": "http://www.brooklyncb6.org/", 450 | "twitter": "brooklyncb6", 451 | "borough": "Brooklyn", 452 | "calendar": { 453 | "web": "http://www1.nyc.gov/site/brooklyncb6/calendar/calendar.page", 454 | "scraperId": "brooklyn-cb6" 455 | } 456 | }, 457 | "307": { 458 | "id": 307, 459 | "number": 7, 460 | "neighborhoods": "Sunset Park and Windsor Terrace", 461 | "address": "4201 4th Avenue,\nBrooklyn, NY 11232", 462 | "phone": "718-854-0003", 463 | "fax": "718-436-1142", 464 | "email": "bk07@cb.nyc.gov", 465 | "website": "http://www.brooklyncb7.org/", 466 | "twitter": "bkcb7", 467 | "borough": "Brooklyn", 468 | "calendar": { 469 | "web": "http://www1.nyc.gov/site/brooklyncb7/calendar/calendar.page" 470 | } 471 | }, 472 | "308": { 473 | "id": 308, 474 | "number": 8, 475 | "neighborhoods": "Crown Heights, Prospect Heights, and Weeksville", 476 | "address": "1291 St. Marks Avenue,\nBrooklyn, NY 11213", 477 | "phone": "718-467-5574", 478 | "fax": "718-778-2979", 479 | "email": "info@brooklyncb8.org", 480 | "website": "http://www.brooklyncb8.org/", 481 | "borough": "Brooklyn", 482 | "calendar": { 483 | "web": "http://www.brooklyncb8.org/meetings/", 484 | "googleCalendarId": "u0jm9q72uejq6doel8n061pngs@group.calendar.google.com" 485 | } 486 | }, 487 | "309": { 488 | "id": 309, 489 | "number": 9, 490 | "neighborhoods": "Crown Heights, Prospect Lefferts Gardens, and Wingate", 491 | "address": "890 Nostrand Avenue,\nBrooklyn, NY 11225", 492 | "phone": "718-778-9279", 493 | "fax": "718-467-0994", 494 | "email": "bk09@cb.nyc.gov", 495 | "website": "http://www.communitybrd9bklyn.org/", 496 | "borough": "Brooklyn", 497 | "calendar": { 498 | "web": "http://www.communitybrd9bklyn.org/meetings/", 499 | "scraperId": "brooklyn-cb9" 500 | } 501 | }, 502 | "310": { 503 | "id": 310, 504 | "number": 10, 505 | "neighborhoods": "Bay Ridge, Dyker Heights, and Fort Hamilton", 506 | "address": "8119 5th Avenue\nBrooklyn, New York 11209", 507 | "phone": "718-745-6827", 508 | "fax": "718-836-2447", 509 | "email": "communitybd10@nyc.rr.com", 510 | "website": "http://www.bkcb10.org/", 511 | "borough": "Brooklyn", 512 | "calendar": { 513 | "web": "http://www1.nyc.gov/site/brooklyncb10/calendar/meeting-calendar.page" 514 | } 515 | }, 516 | "311": { 517 | "id": 311, 518 | "number": 11, 519 | "neighborhoods": "Bath Beach, Gravesend, Mapleton, and Bensonhurst", 520 | "address": "2214 Bath Avenue,\nBrooklyn, NY 11214", 521 | "phone": "718-266-8800", 522 | "fax": "718-266-8821", 523 | "email": "info@brooklyncb11.org", 524 | "website": "http://www.brooklyncb11.org/", 525 | "borough": "Brooklyn", 526 | "calendar": { 527 | "web": "http://www.brooklyncb11.org/calendar/", 528 | "ical": "http://www.brooklyncb11.org/calendar/?ical=1&tribe_display=month", 529 | "googleCalendarId": "rqbr8hduf3jkdupjd5oerch0vag8n7pn@import.calendar.google.com" 530 | } 531 | }, 532 | "312": { 533 | "id": 312, 534 | "number": 12, 535 | "neighborhoods": "Boro Park, Kensington, Ocean Parkway, and Midwood", 536 | "address": "5910 13th Avenue,\nBrooklyn, NY 11219", 537 | "phone": "718-851-0800", 538 | "fax": "718-851-4140", 539 | "email": "BKCB12@gmail.com", 540 | "website": "http://www.brooklyncb12.org", 541 | "twitter": "brooklyncb12", 542 | "borough": "Brooklyn", 543 | "calendar": { 544 | "web": "http://cb12.lifeofmh.com/meetings/" 545 | } 546 | }, 547 | "313": { 548 | "id": 313, 549 | "number": 13, 550 | "neighborhoods": "Coney Island, Brighton Beach, Bensonhurst, Gravesend, and Seagate", 551 | "address": "1201 Surf Avenue, 3rd", 552 | "phone": "718-266-3001", 553 | "fax": "718-266-3920", 554 | "email": "edmark@cb.nyc.gov", 555 | "website": "http://www1.nyc.gov/site/brooklyncb13/", 556 | "borough": "Brooklyn", 557 | "calendar": { 558 | "web": "http://www1.nyc.gov/site/brooklyncb13/calendar/calendar.page" 559 | } 560 | }, 561 | "314": { 562 | "id": 314, 563 | "number": 14, 564 | "neighborhoods": "Flatbush, Midwood, Kensington, and Ocean Parkway", 565 | "address": "810 East 16th Street,\nBrooklyn, NY 11230-3010", 566 | "phone": "718-859-6357", 567 | "fax": "718-421-6077", 568 | "email": "info@cb14brooklyn.com", 569 | "website": "http://www.cb14brooklyn.com", 570 | "twitter": "cb14brooklyn", 571 | "borough": "Brooklyn", 572 | "calendar": { 573 | "web": "http://www.cb14brooklyn.com/category/meetings/", 574 | "ical": "webcal://www.cb14brooklyn.com/?ec3_ical", 575 | "googleCalendarId": "fdnp27905iqi26rhjl3cgvs9qri1gfig@import.calendar.google.com" 576 | } 577 | }, 578 | "315": { 579 | "id": 315, 580 | "number": 15, 581 | "neighborhoods": "Sheepshead Bay, Manhattan Beach, Kings Bay, Gerritsen Beach, Kings Highway, East Gravesend, Madison, Homecrest, and Plum Beach", 582 | "address": "Kingsboro Community College,\n2001 Oriental Boulevard, C Cluster,\nRoom C124\nBrooklyn, NY 11235", 583 | "phone": "718-332-3008", 584 | "fax": "718-648-7232", 585 | "email": "bklcb15@verizon.net", 586 | "website": "http://www.nyc.gov/brooklyncb15", 587 | "borough": "Brooklyn", 588 | "calendar": { 589 | "web": "http://www1.nyc.gov/site/brooklyncb15/calendar/calendar.page" 590 | } 591 | }, 592 | "316": { 593 | "id": 316, 594 | "number": 16, 595 | "neighborhoods": "Brownsville and Ocean Hill", 596 | "address": "444 Thomas Boyland Street, Rm. 103\nBrooklyn, NY 11212", 597 | "phone": "718-385-0323", 598 | "fax": "718-342-6714", 599 | "email": "bk16@cb.nyc.gov", 600 | "website": "http://www.brooklyncb16.org/", 601 | "borough": "Brooklyn", 602 | "calendar": { 603 | "web": "http://www1.nyc.gov/site/brooklyncb16/about/calendar.page" 604 | } 605 | }, 606 | "317": { 607 | "id": 317, 608 | "number": 17, 609 | "neighborhoods": "East Flatbush, Remsen Village, Farragut, Rugby, Erasmus and Ditmas Village", 610 | "address": "4112 Farragut Road\nBrooklyn, New York 11210", 611 | "phone": "718-434-3072", 612 | "fax": "718-434-3801", 613 | "email": "", 614 | "website": "http://cb17brooklyn.org/", 615 | "twitter": "bkcb17", 616 | "borough": "Brooklyn", 617 | "calendar": { 618 | "web": "http://cb17brooklyn.org/events/", 619 | "ical": "http://cb17brooklyn.org/events/?ical=1&tribe_display=month", 620 | "googleCalendarId": "9a4c5488jttj38i3o7njtmt627jogppu@import.calendar.google.com" 621 | } 622 | }, 623 | "318": { 624 | "id": 318, 625 | "number": 18, 626 | "neighborhoods": "Canarsie, Bergen Beach, Mill Basin, Flatlands, Marine Park, Georgetown, and Mill Island", 627 | "address": "1097 Bergen Avenue\nBrooklyn, NY 11234-4841", 628 | "phone": "718-241-0422", 629 | "fax": "718-531-3199", 630 | "email": "bkbrd18@optonline.net", 631 | "website": "", 632 | "borough": "Brooklyn" 633 | }, 634 | "401": { 635 | "id": 401, 636 | "number": 1, 637 | "neighborhoods": "Astoria, Old Astoria, Long Island City, Queensbridge, Ditmars, Ravenswood, Steinway, Garden Bay, and Woodside", 638 | "address": "45-02 Ditmars Boulevard\nLL Suite 125\nAstoria, NY 11105", 639 | "phone": "718-626-1021", 640 | "fax": "718-626-1072", 641 | "email": "qn01@cb.nyc.gov", 642 | "website": "http://www.nyc.gov/html/qnscb1/html/home/home.shtml", 643 | "twitter": "cb1queens", 644 | "borough": "Queens", 645 | "calendar": { 646 | "web": "http://www1.nyc.gov/site/queenscb1/calendar/calendar.page" 647 | } 648 | }, 649 | "402": { 650 | "id": 402, 651 | "number": 2, 652 | "neighborhoods": "Long Island City, Woodside, and Sunnyside", 653 | "address": "43-22 50th Street,\nWoodside, NY 11377", 654 | "phone": "718-533-8773", 655 | "fax": "718-533-8777", 656 | "email": "qn02@cb.nyc.gov", 657 | "website": "http://www.nyc.gov/html/qnscb2/html/home/home.shtml", 658 | "borough": "Queens", 659 | "calendar": { 660 | "web": "http://www.nyc.gov/html/qnscb2/html/calendar/calendar.shtml", 661 | "scraperId": "queens-cb2" 662 | } 663 | }, 664 | "403": { 665 | "id": 403, 666 | "number": 3, 667 | "neighborhoods": "Jackson Heights, East Elmhurst, North Corona, and La Guardia Airport", 668 | "address": "82-11 37th Avenue, Suite 606\nJackson Heights, NY 11372", 669 | "phone": "718-458-2707", 670 | "fax": "718-458-3316", 671 | "email": "communityboard3@nyc.rr.com", 672 | "website": "http://www.cb3qn.nyc.gov/", 673 | "twitter": "communityboard3", 674 | "borough": "Queens", 675 | "calendar": { 676 | "web": "http://www.cb3qn.nyc.gov/calendar", 677 | "scraperId": "queens-cb3" 678 | } 679 | }, 680 | "404": { 681 | "id": 404, 682 | "number": 4, 683 | "neighborhoods": "Corona, Corona Heights, Elmhurst, and Newtown", 684 | "address": "46-11 104th Street\nCorona, NY 11368", 685 | "phone": "718-760-3141", 686 | "fax": "718-760-5971", 687 | "email": "QN04@cb.nyc.gov", 688 | "website": "http://www.nyc.gov/html/qnscb4/html/home/home.shtml", 689 | "twitter": "queenscb4", 690 | "borough": "Queens" 691 | }, 692 | "405": { 693 | "id": 405, 694 | "number": 5, 695 | "neighborhoods": "Ridgewood, Glendale, Middle Village, Maspeth, and Liberty Park", 696 | "address": "61-23 Myrtle Avenue\nGlendale, NY 11385", 697 | "phone": "718.366.1834", 698 | "fax": "718.417.5799", 699 | "email": "qnscb5@nyc.rr.com", 700 | "website": "http://www1.nyc.gov/site/queenscb5/index.page", 701 | "borough": "Queens", 702 | "calendar": { 703 | "web": "http://www1.nyc.gov/site/queenscb5/calendar/calendar.page", 704 | "scraperId": "queens-cb5" 705 | } 706 | }, 707 | "406": { 708 | "id": 406, 709 | "number": 6, 710 | "neighborhoods": "Forest Hills and Rego Park", 711 | "address": "104-01 Metropolitan Avenue\nForest Hills, NY 11375", 712 | "phone": "718-263-9250", 713 | "fax": "718-263-2211", 714 | "email": "cb6q@nyc.rr.com", 715 | "website": "http://queenscb6.org/", 716 | "borough": "Queens", 717 | "calendar": { 718 | "web": "http://www1.nyc.gov/site/queenscb6/news/upcoming-events.page", 719 | "scraperId": "queens-cb6" 720 | } 721 | }, 722 | "407": { 723 | "id": 407, 724 | "number": 7, 725 | "neighborhoods": "Flushing, Bay Terrace, College Point, Whitestone, Malba, Beechhurst, Queensboro Hill, and Willets Point", 726 | "address": "133-32 41st Road - Room 3B\nFlushing, N.Y. 11355", 727 | "phone": "718-359-2800", 728 | "fax": "718-463-3891", 729 | "email": "qn07@cb.nyc.gov", 730 | "website": "http://www.nyc.gov/html/qnscb7/html/home/home.shtml", 731 | "borough": "Queens", 732 | "calendar": { 733 | "web": "http://www.nyc.gov/html/qnscb7/html/meetings/announcements.shtml" 734 | } 735 | }, 736 | "408": { 737 | "id": 408, 738 | "number": 8, 739 | "neighborhoods": "Fresh Meadows, Cunningham Heights, Hilltop Village, Pomonak Houses, Fresh Meadows, Jamaica Estates, Holliswood, Flushing South, Utopia, Kew Gardens Hills, and Briarwood", 740 | "address": "197-15 Hillside Avenue,\nHollis, NY 11423", 741 | "phone": "718-264-7895", 742 | "fax": "718-264-7910", 743 | "email": "qn08@cb.nyc.gov", 744 | "website": "http://www.nyc.gov/html/qnscb8", 745 | "borough": "Queens", 746 | "calendar": { 747 | "web": "http://www1.nyc.gov/site/queenscb8/calendar/calendar.page" 748 | } 749 | }, 750 | "409": { 751 | "id": 409, 752 | "number": 9, 753 | "neighborhoods": "Richmond Hill, Woodhaven, Ozone Park, and Kew Gardens", 754 | "address": "Queens Borough Hall,\n120-55 Queens Boulevard, Rm. 310A\nKew Gardens, NY 11424", 755 | "phone": "718-286-2686", 756 | "fax": "718-286-2685", 757 | "email": "communitybd9@nyc.rr.com", 758 | "website": "http://www.nyc.gov/queenscb9", 759 | "borough": "Queens", 760 | "calendar": { 761 | "web": "http://www1.nyc.gov/site/queenscb9/calendar/calendar.page" 762 | } 763 | }, 764 | "410": { 765 | "id": 410, 766 | "number": 10, 767 | "neighborhoods": "Howard Beach, Ozone Park, South Ozone Park, Richmond Hill, Tudor Village, and Lindenwood", 768 | "address": "115-01 Lefferts Boulevard,\nSouth Ozone", 769 | "phone": "718-843-4488", 770 | "fax": "718-738-1184", 771 | "email": "cb10qns@nyc.rr.com", 772 | "website": "http://www.nyc.gov/html/qnscb10/html/home/home.shtml", 773 | "borough": "Queens" 774 | }, 775 | "411": { 776 | "id": 411, 777 | "number": 11, 778 | "neighborhoods": "Bayside, Douglaston, Little Neck, Auburndale, East Flushing, Oakland Gardens, and Hollis Hills", 779 | "address": "46-21 Little Neck", 780 | "phone": "718-225-1054", 781 | "fax": "718-225-4514", 782 | "email": "cb11q@nyc.rr.com", 783 | "website": "http://www.nyc.gov/html/qnscb11/html/home/home.shtml", 784 | "borough": "Queens" 785 | }, 786 | "412": { 787 | "id": 412, 788 | "number": 12, 789 | "neighborhoods": "Jamaica, Hollis, St. Albans, Springfield Gardens, Baisley Park, Rochdale Village, and South Jamaica", 790 | "address": "90-28 161st Street,\nJamaica, NY 11432", 791 | "phone": "718-658-3308", 792 | "fax": "718-739-6997", 793 | "email": "qn12@cb.nyc.gov", 794 | "website": "http://www.nyc.gov/qcb12", 795 | "borough": "Queens", 796 | "calendar": { 797 | "web": "http://www.nyc.gov/html/qnscb12/html/calendar/cb12-calendar.shtml" 798 | } 799 | }, 800 | "413": { 801 | "id": 413, 802 | "number": 13, 803 | "neighborhoods": "Queens Village, Glen Oaks, New Hyde Park, Bellerose, Cambria Heights, Laurelton, Rosedale, Floral Park, and Brookville", 804 | "address": "Queens Reform Church,\n219-41 Jamaica Avenue,\nQueens Village, NY 11428", 805 | "phone": "718-464-9700", 806 | "fax": "718-264-2739", 807 | "email": "larry.cbthirteen@verizon.net", 808 | "website": "http://www.nyc.gov/html/qnscb13/html/home/home.shtml", 809 | "borough": "Queens", 810 | "calendar": { 811 | "web": "http://www1.nyc.gov/site/queenscb13/calendar/calendar.page" 812 | } 813 | }, 814 | "414": { 815 | "id": 414, 816 | "number": 14, 817 | "neighborhoods": "Breezy Point, Belle Harbor, Broad Channel, Neponsit, Arverne, Bayswater, Edgemere, Rockaway Park, Rockaway and Far Rockaway", 818 | "address": "1931 Mott Avenue,", 819 | "phone": "718-471-7300", 820 | "fax": "718-868-2657", 821 | "email": "cbrock14@nyc.rr.com", 822 | "website": "http://www.queenscb14.org", 823 | "borough": "Queens", 824 | "calendar": { 825 | "web": "http://www1.nyc.gov/site/queenscb14/calendar/calendar.page" 826 | } 827 | }, 828 | "501": { 829 | "id": 501, 830 | "number": 1, 831 | "neighborhoods": "Arlington, Castleton Corners, Clifton, Concord, Elm Park, Fort Wadsworth, Graniteville, Grymes Hill, Livingston, Mariners Harbor, Meiers Corners, New Brighton, Port Ivory, Port Richmond, Randall Manor, Rosebank, St. George, Shore Acres, Silver Lake, Stapleton, Sunnyside, Tompkinsville, West Brighton, Westerleigh", 832 | "address": "1 Edgewater", 833 | "phone": "718-981-6900", 834 | "fax": "718-720-1342", 835 | "email": "sicb1@si.rr.com", 836 | "website": "http://www.nyc.gov/sicb1", 837 | "borough": "Staten Island" 838 | }, 839 | "502": { 840 | "id": 502, 841 | "number": 2, 842 | "neighborhoods": "Arrochar, Bloomfield, Bulls Heads, Chelsea, Dongan Hills, Egbertville, Emerson Hill, Grant City, Grasmere, High Rock, Lighthouse Hill, Midland Beach, New Dorp, New Springville, Oakwood, Ocean Breeze, Old Town, Richmondtown, South Beach, Todt Hill, and Travis", 843 | "address": "Sea View Hospital\nLou Caravone Community Service Building,\n460 Brielle Avenue,\nStaten Island, NY 10314", 844 | "phone": "718-317-3235", 845 | "fax": "718-317-3251", 846 | "email": "", 847 | "website": "http://www.cb2si.com/", 848 | "twitter": "cb2si", 849 | "borough": "Staten Island", 850 | "calendar": { 851 | "web": "http://www.cb2si.com/calendar/", 852 | "googleCalendarId": "communityboard2si@gmail.com" 853 | } 854 | }, 855 | "503": { 856 | "id": 503, 857 | "number": 3, 858 | "neighborhoods": "Annadale, Arden Heights, Bay Terrace, Charleston, Eltingville, Great Kills, Greenridge, Huguenot, Pleasant Plains, Prince's Bay, Richmondtown, Richmond Valley, Rossville, Tottenville, and Woodrow", 859 | "address": "655-218 Rossville Avenue,\nStaten Island, NY 10309", 860 | "phone": "718-356-7900", 861 | "fax": "718-966-9013", 862 | "email": "sicb3@cb.nyc.gov", 863 | "website": "http://www.nyc.gov/sicb3", 864 | "borough": "Staten Island" 865 | } 866 | } 867 | -------------------------------------------------------------------------------- /frontend/src/shared/data/districts-leadership.json: -------------------------------------------------------------------------------- 1 | { 2 | "101":{ 3 | "communityBoard": "1", 4 | "chair": "Anthony Notaro, Jr.", 5 | "districtManager": "Noah Pfefferblit", 6 | "boardMeetingCabinetMeeting": "Fourth Tuesday, 6:00pm", 7 | "address1": "1 Centre Street, New York, NY 10007", 8 | "address2": "Room 2202-N", 9 | "phoneNumber": "212-669-7970", 10 | "postcode": "10007", 11 | "borough": "MANHATTAN", 12 | "latitude": "40.713001", 13 | "longitude": "-74.004181", 14 | "councilDistrict": "1", 15 | "censusTract": "29", 16 | "bin": "1001394", 17 | "bbl": "1001210001", 18 | "nta": "Chinatown", 19 | "location1": "(40.713001, -74.004181)" 20 | }, 21 | "102":{ 22 | "communityBoard": "2", 23 | "chair": "Terri Cude", 24 | "districtManager": "Bob Gormley", 25 | "boardMeetingCabinetMeeting": "Second to last Thursday, 6:30pm", 26 | "address1": "3 Washington Square Village, \n New York, NY 10012", 27 | "address2": "#1A", 28 | "phoneNumber": "212-979-2272", 29 | "postcode": "10012", 30 | "borough": "MANHATTAN", 31 | "latitude": "40.727881", 32 | "longitude": "-73.998557", 33 | "councilDistrict": "1", 34 | "censusTract": "5501", 35 | "bin": "1077835", 36 | "bbl": "1005330001", 37 | "nta": "West Village", 38 | "location1": "(40.727881, -73.998557)" 39 | }, 40 | "103":{ 41 | "communityBoard": "3", 42 | "chair": "Gigi Li", 43 | "districtManager": "Susan Stetzer", 44 | "boardMeetingCabinetMeeting": "Fourth Tuesday, 6:30pm", 45 | "address1": "59 East 4th Street, New York, NY 10003", 46 | "address2": "", 47 | "phoneNumber": "212-533-5300", 48 | "postcode": "10003", 49 | "borough": "MANHATTAN", 50 | "latitude": "40.726785", 51 | "longitude": "-73.990807", 52 | "councilDistrict": "2", 53 | "censusTract": "38", 54 | "bin": "1082642", 55 | "bbl": "1004600056", 56 | "nta": "East Village", 57 | "location1": "(40.726785, -73.990807)" 58 | }, 59 | "104":{ 60 | "communityBoard": "4", 61 | "chair": "Delores Rubin", 62 | "districtManager": "Jesse Bodine", 63 | "boardMeetingCabinetMeeting": "First Wednesday, 6:30pm", 64 | "address1": "330 West 42nd Street,\n New York, NY 10036", 65 | "address2": "Suite 2618", 66 | "phoneNumber": "212-736-4536", 67 | "postcode": "10036", 68 | "borough": "MANHATTAN", 69 | "latitude": "40.757661", 70 | "longitude": "-73.990832", 71 | "councilDistrict": "3", 72 | "censusTract": "115", 73 | "bin": "1024926", 74 | "bbl": "1010320048", 75 | "nta": "Clinton", 76 | "location1": "(40.757661, -73.990832)" 77 | }, 78 | "105":{ 79 | "communityBoard": "5", 80 | "chair": "Vikki Babero", 81 | "districtManager": "Wally Rubin", 82 | "boardMeetingCabinetMeeting": "Second Thursday, 6:00pm", 83 | "address1": "450 7th Avenue, New York, NY 10123", 84 | "address2": "Room 2109", 85 | "phoneNumber": "212-465-0907", 86 | "postcode": "10123", 87 | "borough": "MANHATTAN", 88 | "latitude": "40.751359", 89 | "longitude": "-73.990367", 90 | "councilDistrict": "3", 91 | "censusTract": "109", 92 | "bin": "1014408", 93 | "bbl": "1007840041", 94 | "nta": "Midtown-Midtown South", 95 | "location1": "(40.751359, -73.990367)" 96 | }, 97 | "106":{ 98 | "communityBoard": "6", 99 | "chair": "Richard Eggers", 100 | "districtManager": "Jesus Perez", 101 | "boardMeetingCabinetMeeting": "Second Wednesday, 7:00pm", 102 | "address1": "P.O. Box 1672, New York, NY 10159-1672", 103 | "address2": "", 104 | "phoneNumber": "212-319-3750", 105 | "postcode": "", 106 | "borough": "", 107 | "latitude": "", 108 | "longitude": "", 109 | "councilDistrict": "", 110 | "censusTract": "", 111 | "bin": "", 112 | "bbl": "", 113 | "nta": "", 114 | "location1": "" 115 | }, 116 | "107":{ 117 | "communityBoard": "7", 118 | "chair": "Elizabeth R. Caputo", 119 | "districtManager": "Penny Ryan", 120 | "boardMeetingCabinetMeeting": "First Tuesday, 6:30pm", 121 | "address1": "250 W. 87th Street, New York, NY 10024", 122 | "address2": "", 123 | "phoneNumber": "212-362-4008", 124 | "postcode": "10024", 125 | "borough": "MANHATTAN", 126 | "latitude": "40.789078", 127 | "longitude": "-73.976245", 128 | "councilDistrict": "6", 129 | "censusTract": "175", 130 | "bin": "1076251", 131 | "bbl": "1012347501", 132 | "nta": "Upper West Side", 133 | "location1": "(40.789078, -73.976245)" 134 | }, 135 | "108":{ 136 | "communityBoard": "8", 137 | "chair": "James Clynes", 138 | "districtManager": "Latha Thompson", 139 | "boardMeetingCabinetMeeting": "Third Wednesday, 6:30pm", 140 | "address1": "505 Park Avenue, \n New York, NY 10022", 141 | "address2": "Suite 620", 142 | "phoneNumber": "212-758-4340", 143 | "postcode": "10022", 144 | "borough": "MANHATTAN", 145 | "latitude": "40.763117", 146 | "longitude": "-73.969641", 147 | "councilDistrict": "4", 148 | "censusTract": "11402", 149 | "bin": "1041900", 150 | "bbl": "1013940001", 151 | "nta": "Upper East Side-Carnegie Hill", 152 | "location1": "(40.763117, -73.969641)" 153 | }, 154 | "109":{ 155 | "communityBoard": "9", 156 | "chair": "Padmore John", 157 | "districtManager": "Eutha R. Prince", 158 | "boardMeetingCabinetMeeting": "Third Thursday, 6:30pm", 159 | "address1": "16-18 Old Broadway, New York, NY 10027", 160 | "address2": "", 161 | "phoneNumber": "212-864-6200", 162 | "postcode": "10027", 163 | "borough": "MANHATTAN", 164 | "latitude": "40.815433", 165 | "longitude": "-73.957204", 166 | "councilDistrict": "7", 167 | "censusTract": "219", 168 | "bin": "1059714", 169 | "bbl": "1019820067", 170 | "nta": "Manhattanville", 171 | "location1": "(40.815433, -73.957204)" 172 | }, 173 | "110":{ 174 | "communityBoard": "10", 175 | "chair": "Henrietta Lyle", 176 | "districtManager": "Andrew Lassalle", 177 | "boardMeetingCabinetMeeting": "First Wednesday, 6:00pm", 178 | "address1": "215 West 125th Street, New York, NY 10027", 179 | "address2": "4th Floor", 180 | "phoneNumber": "212-749-3105", 181 | "postcode": "10027", 182 | "borough": "MANHATTAN", 183 | "latitude": "40.809259", 184 | "longitude": "-73.948979", 185 | "councilDistrict": "9", 186 | "censusTract": "222", 187 | "bin": "1058659", 188 | "bbl": "1019310021", 189 | "nta": "Central Harlem South", 190 | "location1": "(40.809259, -73.948979)" 191 | }, 192 | "111":{ 193 | "communityBoard": "11", 194 | "chair": "Diane Collier", 195 | "districtManager": "Angel D. Mescain", 196 | "boardMeetingCabinetMeeting": "Third Tuesday, 6:30pm", 197 | "address1": "1664 Park Avenue, New York, NY 10035", 198 | "address2": "Ground Floor", 199 | "phoneNumber": "212-831-8929", 200 | "postcode": "10035", 201 | "borough": "MANHATTAN", 202 | "latitude": "40.800223", 203 | "longitude": "-73.942564", 204 | "councilDistrict": "9", 205 | "censusTract": "184", 206 | "bin": "1051688", 207 | "bbl": "1016230035", 208 | "nta": "East Harlem North", 209 | "location1": "(40.800223, -73.942564)" 210 | }, 211 | "112":{ 212 | "communityBoard": "12", 213 | "chair": "Shahabudeen (Shah) Ally", 214 | "districtManager": "Ebenezer Smith", 215 | "boardMeetingCabinetMeeting": "Fourth Tuesday, 7:00pm", 216 | "address1": "530 West 166th Street, New York, NY 10032", 217 | "address2": "6th Floor", 218 | "phoneNumber": "212-568-8500", 219 | "postcode": "10032", 220 | "borough": "MANHATTAN", 221 | "latitude": "40.839328", 222 | "longitude": "-73.939339", 223 | "councilDistrict": "10", 224 | "censusTract": "251", 225 | "bin": "1062988", 226 | "bbl": "1021240001", 227 | "nta": "Washington Heights South", 228 | "location1": "(40.839328, -73.939339)" 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /frontend/src/shared/google/GCalApi.ts: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | 3 | export module GCalApi { 4 | // https://developers.google.com/google-apps/calendar/v3/reference/events/list 5 | export interface EventsList { 6 | items: EventsItem[]; 7 | } 8 | 9 | // https://developers.google.com/google-apps/calendar/v3/reference/events#resource 10 | export interface EventsItem { 11 | id: string; 12 | summary: string; 13 | description: string; 14 | location: string; 15 | start: DateOrDateTime; 16 | end: DateOrDateTime; 17 | } 18 | 19 | interface DateOrDateTime { 20 | date?: string; 21 | dateTime?: string; 22 | } 23 | 24 | export function fetchFeed(calendarId: string): Promise { 25 | return fetch(feedUrl(calendarId)) 26 | .then((resp) => { 27 | if (!resp.ok) { 28 | throw Error(`${resp.status} ${resp.statusText}`); 29 | } 30 | return resp.json(); 31 | }) 32 | .then((json) => json as GCalApi.EventsList) 33 | .then((list) => list.items 34 | .map((item) => ({ 35 | id: item.id, 36 | date: item.start.dateTime || item.start.date!, 37 | summary: item.summary, 38 | location: item.location, 39 | description: item.description 40 | })) 41 | .sort((a, b) => moment(a.date).valueOf() - moment(b.date).valueOf()) 42 | ); 43 | } 44 | 45 | function feedUrl(calendarId: string) { 46 | const calendarIdEncoded = encodeURIComponent(calendarId); 47 | const timeMin = encodeURIComponent(moment().subtract(1, 'day').format()); 48 | const timeMax = encodeURIComponent(moment().add(1, 'month').format()); 49 | return 'https://clients6.google.com/calendar/v3/calendars/' 50 | + calendarId 51 | + '/events' 52 | + `?calendarId=${calendarIdEncoded}` 53 | + '&singleEvents=true' 54 | + '&timeZone=America%2FNew_York' 55 | + '&maxAttendees=1' 56 | + '&maxResults=250' 57 | + '&sanitizeHtml=true' 58 | + `&timeMin=${timeMin}` 59 | + `&timeMax=${timeMax}` 60 | + '&key=AIzaSyBNlYH01_9Hc5S1J9vuFmu2nUqBZJNAXxs'; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /frontend/src/shared/icons/Twitter.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import SvgIcon from 'material-ui/SvgIcon'; 3 | 4 | /* tslint:disable */ 5 | 6 | export default class Twitter extends Component { 7 | 8 | render() { 9 | return ( 10 | 11 | 12 | 13 | ); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/shared/models/Calendar.ts: -------------------------------------------------------------------------------- 1 | import { GCalApi } from '../google/GCalApi'; 2 | import moment from 'moment'; 3 | 4 | export interface CalendarData { 5 | ical?: string; 6 | web?: string; 7 | scraperId?: string; 8 | googleCalendarId?: string; 9 | } 10 | 11 | export class Calendar { 12 | 13 | private readonly data: CalendarData; 14 | 15 | constructor(data: CalendarData) { 16 | this.data = data; 17 | } 18 | 19 | get events(): Promise { 20 | if (this.data.googleCalendarId) { 21 | return GCalApi.fetchFeed(this.data.googleCalendarId); 22 | } 23 | if (this.data.scraperId) { 24 | const url = `/scraper-data/${this.data.scraperId}.json`; 25 | return fetch(url) 26 | .then((resp) => { 27 | if (!resp.ok) { 28 | throw Error(`${resp.status} ${resp.statusText}`); 29 | } 30 | return resp.json() as Promise; 31 | }) 32 | .then((events) => 33 | events.filter((event) => { 34 | const timeMin = moment().subtract(1, 'day'); 35 | const timeMax = moment().add(1, 'month'); 36 | const eventDate = moment(event.date); 37 | return eventDate.isAfter(timeMin) && eventDate.isBefore(timeMax); 38 | })); 39 | } 40 | return Promise.reject('Feed not available'); 41 | } 42 | 43 | get icalUrl(): string | undefined { 44 | if (this.data.ical) { 45 | return new URL(this.data.ical, window.location.href).toString(); 46 | } else if (this.data.scraperId) { 47 | return new URL(`/scraper-data/${this.data.scraperId}.ics`, window.location.href).toString(); 48 | } else if (this.data.googleCalendarId) { 49 | const cidComponent = encodeURIComponent(this.data.googleCalendarId); 50 | return `https://calendar.google.com/calendar/ical/${cidComponent}/public/basic.ics`; 51 | } 52 | return undefined; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /frontend/src/shared/models/CalendarEvent.ts: -------------------------------------------------------------------------------- 1 | interface CalendarEvent { 2 | id: string; 3 | date: string; 4 | summary: string; 5 | description: string; 6 | location: string; 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/shared/models/ComponentSizes.ts: -------------------------------------------------------------------------------- 1 | export interface ComponentSizes { 2 | app: Size; 3 | sidebar?: Size; 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/shared/models/District.ts: -------------------------------------------------------------------------------- 1 | import { CalendarData } from './Calendar'; 2 | 3 | export default interface District { 4 | id: number; 5 | borough: string; 6 | number: string; 7 | neighborhoods: string; 8 | address?: string; 9 | email?: string; 10 | phone?: string; 11 | twitter?: string; 12 | website?: string; 13 | calendar?: CalendarData; 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/shared/models/DistrictLeadership.ts: -------------------------------------------------------------------------------- 1 | export default interface DistrictLeadership { 2 | communityBoard: string; 3 | chair: string; 4 | districtManager: string; 5 | boardMeetingCabinetMeeting: string; 6 | address1: string; 7 | address2: string; 8 | phoneNumber: string; 9 | postcode: string; 10 | borough: string; 11 | latitude: string; 12 | longitude: string; 13 | councilDistrict: string; 14 | censusTract: string; 15 | bin: string; 16 | bbl: string; 17 | nta: string; 18 | location1: string; 19 | } 20 | -------------------------------------------------------------------------------- /frontend/src/shared/models/Location.ts: -------------------------------------------------------------------------------- 1 | import LngLatLike = mapboxgl.LngLatLike; 2 | import { Coord } from '@turf/helpers'; 3 | 4 | export default interface Location { 5 | place_name?: string; 6 | center: LngLatLike & Coord; 7 | } -------------------------------------------------------------------------------- /frontend/src/shared/models/RootState.ts: -------------------------------------------------------------------------------- 1 | import Location from './Location'; 2 | import { ComponentSizes } from './ComponentSizes'; 3 | import { RouterState } from 'react-router-redux'; 4 | 5 | export interface RootState { 6 | selectedLocation?: Location; 7 | componentSizes: ComponentSizes; 8 | routing?: RouterState; 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/shared/models/Size.ts: -------------------------------------------------------------------------------- 1 | interface Size { 2 | width: number; 3 | height: number; 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/shared/polyfills.ts: -------------------------------------------------------------------------------- 1 | import 'core-js/es7/promise'; 2 | -------------------------------------------------------------------------------- /frontend/src/shared/reactGAMiddlewares.js: -------------------------------------------------------------------------------- 1 | import ReactGA from 'react-ga' 2 | 3 | const options = {}; 4 | 5 | const trackPage = (page) => { 6 | ReactGA.set({ 7 | page, 8 | ...options 9 | }); 10 | ReactGA.pageview(page) 11 | }; 12 | 13 | let currentPage = ''; 14 | 15 | export const googleAnalytics = store => next => action => { 16 | if (action.type === '@@router/LOCATION_CHANGE') { 17 | const nextPage = `${action.payload.pathname}${action.payload.search}`; 18 | 19 | if (currentPage !== nextPage) { 20 | currentPage = nextPage; 21 | trackPage(nextPage) 22 | } 23 | } 24 | 25 | return next(action) 26 | }; 27 | -------------------------------------------------------------------------------- /frontend/src/shared/reducers/component-sizes.ts: -------------------------------------------------------------------------------- 1 | import { ActionType, RootAction } from '../actions'; 2 | import { ComponentSizes } from '../models/ComponentSizes'; 3 | 4 | const DEFAULT_STATE: ComponentSizes = { 5 | app: { width: 0, height: 0} 6 | }; 7 | 8 | export default (state: ComponentSizes = DEFAULT_STATE, action: RootAction) => { 9 | switch (action.type) { 10 | case ActionType.COMPONENT_RESIZED: 11 | const newState = { ...state }; 12 | newState[action.id] = action.size; 13 | return newState; 14 | default: 15 | return state; 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /frontend/src/shared/reducers/index.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import selectedLocation from './selected-location'; 3 | import componentSizes from './component-sizes'; 4 | import { routerReducer } from 'react-router-redux'; 5 | 6 | const allReducers = combineReducers({ 7 | selectedLocation, 8 | componentSizes, 9 | routing: routerReducer 10 | }); 11 | 12 | export default allReducers; 13 | -------------------------------------------------------------------------------- /frontend/src/shared/reducers/selected-location.ts: -------------------------------------------------------------------------------- 1 | import { ActionType, RootAction } from '../actions'; 2 | 3 | export default (state = null, action: RootAction) => { 4 | switch (action.type) { 5 | case ActionType.SELECT_LOCATION: 6 | return action.location; 7 | default: 8 | return state; 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /frontend/src/shared/selectors/district-id-from-route.ts: -------------------------------------------------------------------------------- 1 | import { matchPath } from 'react-router'; 2 | import { RootState } from '../models/RootState'; 3 | 4 | interface DistrictIdPathParams { 5 | selectedDistrictId?: string; 6 | } 7 | 8 | const districtIdFromRoute = (state: RootState): number | undefined => { 9 | if (state.routing && state.routing.location) { 10 | const match = matchPath(state.routing.location.pathname, { 11 | path: '/districts/:selectedDistrictId', 12 | exact: true 13 | }); 14 | if (match) { 15 | return parseInt(match.params.selectedDistrictId!, 10); 16 | } 17 | } 18 | return undefined; 19 | }; 20 | 21 | export default districtIdFromRoute; 22 | -------------------------------------------------------------------------------- /frontend/src/shared/selectors/location-from-route.ts: -------------------------------------------------------------------------------- 1 | import { RootState } from '../models/RootState'; 2 | 3 | const locationFromRoute = (state: RootState): string | undefined => { 4 | if (state.routing && state.routing.location) { 5 | const params = new URLSearchParams(state.routing.location.search); 6 | return params.get('q') || undefined; 7 | } 8 | return undefined; 9 | }; 10 | 11 | export default locationFromRoute; 12 | -------------------------------------------------------------------------------- /frontend/src/shared/types/html.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.html' { 2 | const value: string; 3 | export default value; 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/shared/types/json.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.json' { 2 | const value: any; 3 | export default value; 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/shared/types/jss-extend.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'jss-extend' { 2 | import { JSSPlugin } from 'jss'; 3 | function jssExtend(): JSSPlugin; 4 | export = jssExtend; 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/shared/types/mapbox-gl.ts: -------------------------------------------------------------------------------- 1 | import 'mapbox-gl'; 2 | import { Feature, GeoJsonProperties, Polygon } from 'geojson'; 3 | 4 | declare module 'mapbox-gl' { 5 | interface MapMouseEvent { 6 | features: Feature[]; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/shared/types/mapbox.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace mapbox { 2 | interface LatLng { 3 | latitude: number; 4 | longitude: number; 5 | } 6 | 7 | interface GeocodeForwardOptions { 8 | autocomplete: boolean; 9 | bbox: number[]; 10 | proximity: LatLng; 11 | } 12 | 13 | // https://github.com/mapbox/carmen/blob/master/carmen-geojson.md 14 | interface CarmenLocation { 15 | place_name: string; 16 | place_type: string[]; 17 | } 18 | } 19 | 20 | declare module 'mapbox' { 21 | import { ResponsePromise } from "rest"; 22 | import GeocodeForwardOptions = mapbox.GeocodeForwardOptions; 23 | 24 | class MapboxClient { 25 | constructor(token: String); 26 | geocodeForward(query: String, options: GeocodeForwardOptions): ResponsePromise; 27 | } 28 | 29 | export = MapboxClient; 30 | } 31 | -------------------------------------------------------------------------------- /frontend/src/shared/types/md.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.md' { 2 | const value: string; 3 | export default value; 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/shared/types/promised-location.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'promised-location' { 2 | interface PromisedLocationOptions { 3 | enableHighAccuracy: boolean; 4 | timeout: number; 5 | maximumAge: number; 6 | } 7 | 8 | class PromisedLocation extends Promise { 9 | constructor(options: PromisedLocationOptions); 10 | } 11 | 12 | export = PromisedLocation; 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/shared/types/react-jss.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-jss/lib/JssProvider' { 2 | import * as React from 'react'; 3 | import 'react-jss/lib/JssProvider'; 4 | import { GenerateClassName } from 'jss'; 5 | 6 | interface JssProviderProps { 7 | jss: object; 8 | generateClassName: GenerateClassName; 9 | } 10 | 11 | const JssProvider: React.ComponentType; 12 | 13 | export default JssProvider; 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/shared/types/react-resize-detector.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-resize-detector' { 2 | 3 | interface ReactResizeDetectorProps extends React.Props { 4 | handleHeight?: boolean; 5 | handleWidth?: boolean; 6 | onResize: (width: number, height: number) => void; 7 | } 8 | 9 | class ReactResizeDetector extends React.Component { } 10 | 11 | export = ReactResizeDetector; 12 | } 13 | -------------------------------------------------------------------------------- /frontend/src/shared/types/swipeable-views.ts: -------------------------------------------------------------------------------- 1 | export interface SwipeableViewsChildContext { 2 | slideUpdateHeight: () => void; 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/shared/utils/device.ts: -------------------------------------------------------------------------------- 1 | export function isMobileSafari() { 2 | const userAgent = window.navigator.userAgent; 3 | const isIos = !!userAgent.match(/iP(ad|od|hone)/i); 4 | const isWebKit = !!userAgent.match(/WebKit/i); 5 | const isChrome = !!userAgent.match(/CriOS/i); 6 | return isIos && isWebKit && !isChrome; 7 | } 8 | -------------------------------------------------------------------------------- /frontend/static.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "build/", 3 | "routes": { 4 | "/**": "index.html" 5 | } 6 | } 7 | 8 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "outDir": "build/dist", 5 | "module": "esnext", 6 | "target": "es5", 7 | "lib": ["es7", "dom"], 8 | "sourceMap": true, 9 | "allowJs": true, 10 | "jsx": "react", 11 | "moduleResolution": "node", 12 | "rootDir": "src", 13 | "forceConsistentCasingInFileNames": true, 14 | "noImplicitReturns": true, 15 | "noImplicitThis": true, 16 | "noImplicitAny": true, 17 | "strictNullChecks": true, 18 | "suppressImplicitAnyIndexErrors": true, 19 | "noUnusedLocals": true, 20 | "allowSyntheticDefaultImports": true, 21 | "experimentalDecorators": true 22 | }, 23 | "exclude": [ 24 | "node_modules", 25 | "build", 26 | "scripts", 27 | "acceptance-tests", 28 | "webpack", 29 | "jest", 30 | "src/setupTests.ts" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /frontend/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | } 6 | } -------------------------------------------------------------------------------- /frontend/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint-react"], 3 | "rules": { 4 | "align": [ 5 | true, 6 | "parameters", 7 | "arguments", 8 | "statements" 9 | ], 10 | "ban": false, 11 | "class-name": true, 12 | "comment-format": [ 13 | true, 14 | "check-space" 15 | ], 16 | "curly": true, 17 | "eofline": false, 18 | "forin": true, 19 | "indent": [ true, "spaces" ], 20 | "interface-name": [true, "never-prefix"], 21 | "jsdoc-format": true, 22 | "jsx-no-lambda": false, 23 | "jsx-no-multiline-js": false, 24 | "label-position": true, 25 | "max-line-length": [ true, 120 ], 26 | "member-ordering": [ 27 | true, 28 | "public-before-private", 29 | "static-before-instance", 30 | "variables-before-functions" 31 | ], 32 | "no-any": true, 33 | "no-arg": true, 34 | "no-bitwise": true, 35 | "no-console": [ 36 | false, 37 | "log", 38 | "error", 39 | "debug", 40 | "info", 41 | "time", 42 | "timeEnd", 43 | "trace" 44 | ], 45 | "no-consecutive-blank-lines": true, 46 | "no-construct": true, 47 | "no-debugger": true, 48 | "no-duplicate-variable": true, 49 | "no-empty": true, 50 | "no-eval": true, 51 | "no-shadowed-variable": true, 52 | "no-string-literal": true, 53 | "no-switch-case-fall-through": true, 54 | "no-trailing-whitespace": false, 55 | "no-unused-expression": false, 56 | "no-use-before-declare": true, 57 | "one-line": [ 58 | true, 59 | "check-catch", 60 | "check-else", 61 | "check-open-brace", 62 | "check-whitespace" 63 | ], 64 | "quotemark": [true, "single", "jsx-double"], 65 | "radix": true, 66 | "semicolon": [true, "always"], 67 | "switch-default": true, 68 | 69 | "trailing-comma": [false], 70 | 71 | "triple-equals": [ true, "allow-null-check" ], 72 | "typedef": [ 73 | true, 74 | "parameter", 75 | "property-declaration" 76 | ], 77 | "typedef-whitespace": [ 78 | true, 79 | { 80 | "call-signature": "nospace", 81 | "index-signature": "nospace", 82 | "parameter": "nospace", 83 | "property-declaration": "nospace", 84 | "variable-declaration": "nospace" 85 | } 86 | ], 87 | "variable-name": [true, "ban-keywords", "check-format", "allow-leading-underscore", "allow-pascal-case"], 88 | "whitespace": [ 89 | true, 90 | "check-branch", 91 | "check-decl", 92 | "check-module", 93 | "check-operator", 94 | "check-separator", 95 | "check-type", 96 | "check-typecast" 97 | ] 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /scrapers/.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | output/ 3 | __pycache__ 4 | -------------------------------------------------------------------------------- /scrapers/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | 3 | url = "https://pypi.python.org/simple" 4 | verify_ssl = true 5 | name = "pypi" 6 | 7 | 8 | [packages] 9 | 10 | twisted = { git = "https://github.com/twisted/twisted.git", ref="9384-remove-async-param" } 11 | bleach = "*" 12 | scrapy = "*" 13 | "bs4" = "*" 14 | parsedatetime = "*" 15 | icalendar = "*" 16 | pytz = "*" 17 | schedule = "*" 18 | requests = "*" 19 | raven = "*" 20 | 21 | 22 | [dev-packages] 23 | 24 | -------------------------------------------------------------------------------- /scrapers/Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "b194e2c1faeb7102ed7abff4097998cffc980e3d3ced89edc1eabd72a7df3ecb" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": {}, 8 | "sources": [ 9 | { 10 | "name": "pypi", 11 | "url": "https://pypi.python.org/simple", 12 | "verify_ssl": true 13 | } 14 | ] 15 | }, 16 | "default": { 17 | "asn1crypto": { 18 | "hashes": [ 19 | "sha256:2f1adbb7546ed199e3c90ef23ec95c5cf3585bac7d11fb7eb562a3fe89c64e87", 20 | "sha256:9d5c20441baf0cb60a4ac34cc447c6c189024b6b4c6cd7877034f4965c464e49" 21 | ], 22 | "version": "==0.24.0" 23 | }, 24 | "attrs": { 25 | "hashes": [ 26 | "sha256:4b90b09eeeb9b88c35bc642cbac057e45a5fd85367b985bd2809c62b7b939265", 27 | "sha256:e0d0eb91441a3b53dab4d9b743eafc1ac44476296a2053b6ca3af0b139faf87b" 28 | ], 29 | "version": "==18.1.0" 30 | }, 31 | "automat": { 32 | "hashes": [ 33 | "sha256:cbd78b83fa2d81fe2a4d23d258e1661dd7493c9a50ee2f1a5b2cac61c1793b0e", 34 | "sha256:fdccab66b68498af9ecfa1fa43693abe546014dd25cf28543cbe9d1334916a58" 35 | ], 36 | "version": "==0.7.0" 37 | }, 38 | "beautifulsoup4": { 39 | "hashes": [ 40 | "sha256:11a9a27b7d3bddc6d86f59fb76afb70e921a25ac2d6cc55b40d072bd68435a76", 41 | "sha256:7015e76bf32f1f574636c4288399a6de66ce08fb7b2457f628a8d70c0fbabb11", 42 | "sha256:808b6ac932dccb0a4126558f7dfdcf41710dd44a4ef497a0bb59a77f9f078e89" 43 | ], 44 | "version": "==4.6.0" 45 | }, 46 | "bleach": { 47 | "hashes": [ 48 | "sha256:b8fa79e91f96c2c2cd9fd1f9eda906efb1b88b483048978ba62fef680e962b34", 49 | "sha256:eb7386f632349d10d9ce9d4a838b134d4731571851149f9cc2c05a9a837a9a44" 50 | ], 51 | "index": "pypi", 52 | "version": "==2.1.3" 53 | }, 54 | "bs4": { 55 | "hashes": [ 56 | "sha256:36ecea1fd7cc5c0c6e4a1ff075df26d50da647b75376626cc186e2212886dd3a" 57 | ], 58 | "index": "pypi", 59 | "version": "==0.0.1" 60 | }, 61 | "certifi": { 62 | "hashes": [ 63 | "sha256:13e698f54293db9f89122b0581843a782ad0934a4fe0172d2a980ba77fc61bb7", 64 | "sha256:9fa520c1bacfb634fa7af20a76bcbd3d5fb390481724c597da32c719a7dca4b0" 65 | ], 66 | "version": "==2018.4.16" 67 | }, 68 | "cffi": { 69 | "hashes": [ 70 | "sha256:151b7eefd035c56b2b2e1eb9963c90c6302dc15fbd8c1c0a83a163ff2c7d7743", 71 | "sha256:1553d1e99f035ace1c0544050622b7bc963374a00c467edafac50ad7bd276aef", 72 | "sha256:1b0493c091a1898f1136e3f4f991a784437fac3673780ff9de3bcf46c80b6b50", 73 | "sha256:2ba8a45822b7aee805ab49abfe7eec16b90587f7f26df20c71dd89e45a97076f", 74 | "sha256:3bb6bd7266598f318063e584378b8e27c67de998a43362e8fce664c54ee52d30", 75 | "sha256:3c85641778460581c42924384f5e68076d724ceac0f267d66c757f7535069c93", 76 | "sha256:3eb6434197633b7748cea30bf0ba9f66727cdce45117a712b29a443943733257", 77 | "sha256:495c5c2d43bf6cebe0178eb3e88f9c4aa48d8934aa6e3cddb865c058da76756b", 78 | "sha256:4c91af6e967c2015729d3e69c2e51d92f9898c330d6a851bf8f121236f3defd3", 79 | "sha256:57b2533356cb2d8fac1555815929f7f5f14d68ac77b085d2326b571310f34f6e", 80 | "sha256:770f3782b31f50b68627e22f91cb182c48c47c02eb405fd689472aa7b7aa16dc", 81 | "sha256:79f9b6f7c46ae1f8ded75f68cf8ad50e5729ed4d590c74840471fc2823457d04", 82 | "sha256:7a33145e04d44ce95bcd71e522b478d282ad0eafaf34fe1ec5bbd73e662f22b6", 83 | "sha256:857959354ae3a6fa3da6651b966d13b0a8bed6bbc87a0de7b38a549db1d2a359", 84 | "sha256:87f37fe5130574ff76c17cab61e7d2538a16f843bb7bca8ebbc4b12de3078596", 85 | "sha256:95d5251e4b5ca00061f9d9f3d6fe537247e145a8524ae9fd30a2f8fbce993b5b", 86 | "sha256:9d1d3e63a4afdc29bd76ce6aa9d58c771cd1599fbba8cf5057e7860b203710dd", 87 | "sha256:a36c5c154f9d42ec176e6e620cb0dd275744aa1d804786a71ac37dc3661a5e95", 88 | "sha256:a6a5cb8809091ec9ac03edde9304b3ad82ad4466333432b16d78ef40e0cce0d5", 89 | "sha256:ae5e35a2c189d397b91034642cb0eab0e346f776ec2eb44a49a459e6615d6e2e", 90 | "sha256:b0f7d4a3df8f06cf49f9f121bead236e328074de6449866515cea4907bbc63d6", 91 | "sha256:b75110fb114fa366b29a027d0c9be3709579602ae111ff61674d28c93606acca", 92 | "sha256:ba5e697569f84b13640c9e193170e89c13c6244c24400fc57e88724ef610cd31", 93 | "sha256:be2a9b390f77fd7676d80bc3cdc4f8edb940d8c198ed2d8c0be1319018c778e1", 94 | "sha256:ca1bd81f40adc59011f58159e4aa6445fc585a32bb8ac9badf7a2c1aa23822f2", 95 | "sha256:d5d8555d9bfc3f02385c1c37e9f998e2011f0db4f90e250e5bc0c0a85a813085", 96 | "sha256:e55e22ac0a30023426564b1059b035973ec82186ddddbac867078435801c7801", 97 | "sha256:e90f17980e6ab0f3c2f3730e56d1fe9bcba1891eeea58966e89d352492cc74f4", 98 | "sha256:ecbb7b01409e9b782df5ded849c178a0aa7c906cf8c5a67368047daab282b184", 99 | "sha256:ed01918d545a38998bfa5902c7c00e0fee90e957ce036a4000a88e3fe2264917", 100 | "sha256:edabd457cd23a02965166026fd9bfd196f4324fe6032e866d0f3bd0301cd486f", 101 | "sha256:fdf1c1dc5bafc32bc5d08b054f94d659422b05aba244d6be4ddc1c72d9aa70fb" 102 | ], 103 | "markers": "platform_python_implementation != 'PyPy'", 104 | "version": "==1.11.5" 105 | }, 106 | "chardet": { 107 | "hashes": [ 108 | "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", 109 | "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" 110 | ], 111 | "version": "==3.0.4" 112 | }, 113 | "constantly": { 114 | "hashes": [ 115 | "sha256:586372eb92059873e29eba4f9dec8381541b4d3834660707faf8ba59146dfc35", 116 | "sha256:dd2fa9d6b1a51a83f0d7dd76293d734046aa176e384bf6e33b7e44880eb37c5d" 117 | ], 118 | "version": "==15.1.0" 119 | }, 120 | "cryptography": { 121 | "hashes": [ 122 | "sha256:3f3b65d5a16e6b52fba63dc860b62ca9832f51f1a2ae5083c78b6840275f12dd", 123 | "sha256:5251e7de0de66810833606439ca65c9b9e45da62196b0c88bfadf27740aac09f", 124 | "sha256:551a3abfe0c8c6833df4192a63371aa2ff43afd8f570ed345d31f251d78e7e04", 125 | "sha256:5cb990056b7cadcca26813311187ad751ea644712022a3976443691168781b6f", 126 | "sha256:60bda7f12ecb828358be53095fc9c6edda7de8f1ef571f96c00b2363643fa3cd", 127 | "sha256:64b5c67acc9a7c83fbb4b69166f3105a0ab722d27934fac2cb26456718eec2ba", 128 | "sha256:6fef51ec447fe9f8351894024e94736862900d3a9aa2961528e602eb65c92bdb", 129 | "sha256:77d0ad229d47a6e0272d00f6bf8ac06ce14715a9fd02c9a97f5a2869aab3ccb2", 130 | "sha256:808fe471b1a6b777f026f7dc7bd9a4959da4bfab64972f2bbe91e22527c1c037", 131 | "sha256:9b62fb4d18529c84b961efd9187fecbb48e89aa1a0f9f4161c61b7fc42a101bd", 132 | "sha256:9e5bed45ec6b4f828866ac6a6bedf08388ffcfa68abe9e94b34bb40977aba531", 133 | "sha256:9fc295bf69130a342e7a19a39d7bbeb15c0bcaabc7382ec33ef3b2b7d18d2f63", 134 | "sha256:abd070b5849ed64e6d349199bef955ee0ad99aefbad792f0c587f8effa681a5e", 135 | "sha256:ba6a774749b6e510cffc2fb98535f717e0e5fd91c7c99a61d223293df79ab351", 136 | "sha256:c332118647f084c983c6a3e1dba0f3bcb051f69d12baccac68db8d62d177eb8a", 137 | "sha256:d6f46e862ee36df81e6342c2177ba84e70f722d9dc9c6c394f9f1f434c4a5563", 138 | "sha256:db6013746f73bf8edd9c3d1d3f94db635b9422f503db3fc5ef105233d4c011ab", 139 | "sha256:f57008eaff597c69cf692c3518f6d4800f0309253bb138b526a37fe9ef0c7471", 140 | "sha256:f6c821ac253c19f2ad4c8691633ae1d1a17f120d5b01ea1d256d7b602bc59887" 141 | ], 142 | "version": "==2.2.2" 143 | }, 144 | "cssselect": { 145 | "hashes": [ 146 | "sha256:066d8bc5229af09617e24b3ca4d52f1f9092d9e061931f4184cd572885c23204", 147 | "sha256:3b5103e8789da9e936a68d993b70df732d06b8bb9a337a05ed4eb52c17ef7206" 148 | ], 149 | "markers": "python_version != '3.3.*' and python_version != '3.0.*' and python_version != '3.2.*' and python_version >= '2.7' and python_version != '3.1.*'", 150 | "version": "==1.0.3" 151 | }, 152 | "future": { 153 | "hashes": [ 154 | "sha256:e39ced1ab767b5936646cedba8bcce582398233d6a627067d4c6a454c90cfedb" 155 | ], 156 | "version": "==0.16.0" 157 | }, 158 | "html5lib": { 159 | "hashes": [ 160 | "sha256:20b159aa3badc9d5ee8f5c647e5efd02ed2a66ab8d354930bd9ff139fc1dc0a3", 161 | "sha256:66cb0dcfdbbc4f9c3ba1a63fdb511ffdbd4f513b2b6d81b80cd26ce6b3fb3736" 162 | ], 163 | "version": "==1.0.1" 164 | }, 165 | "hyperlink": { 166 | "hashes": [ 167 | "sha256:98da4218a56b448c7ec7d2655cb339af1f7d751cf541469bb4fc28c4a4245b34", 168 | "sha256:f01b4ff744f14bc5d0a22a6b9f1525ab7d6312cb0ff967f59414bbac52f0a306" 169 | ], 170 | "version": "==18.0.0" 171 | }, 172 | "icalendar": { 173 | "hashes": [ 174 | "sha256:2f7166b36ed52e7ab3f2c6aacabb4300095e10c3a6244a53e2f77410633989ae", 175 | "sha256:80362a9f3c2686b88791fdb78c063f33bd96451f7b1b12140c5aad2df81c008c" 176 | ], 177 | "index": "pypi", 178 | "version": "==4.0.2" 179 | }, 180 | "idna": { 181 | "hashes": [ 182 | "sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e", 183 | "sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16" 184 | ], 185 | "version": "==2.7" 186 | }, 187 | "incremental": { 188 | "hashes": [ 189 | "sha256:717e12246dddf231a349175f48d74d93e2897244939173b01974ab6661406b9f", 190 | "sha256:7b751696aaf36eebfab537e458929e194460051ccad279c72b755a167eebd4b3" 191 | ], 192 | "version": "==17.5.0" 193 | }, 194 | "lxml": { 195 | "hashes": [ 196 | "sha256:0941f4313208c07734410414d8308812b044fd3fb98573454e3d3a0d2e201f3d", 197 | "sha256:0b18890aa5730f9d847bc5469e8820f782d72af9985a15a7552109a86b01c113", 198 | "sha256:21f427945f612ac75576632b1bb8c21233393c961f2da890d7be3927a4b6085f", 199 | "sha256:24cf6f622a4d49851afcf63ac4f0f3419754d4e98a7a548ab48dd03c635d9bd3", 200 | "sha256:2dc6705486b8abee1af9e2a3761e30a3cb19e8276f20ca7e137ee6611b93707c", 201 | "sha256:2e43b2e5b7d2b9abe6e0301eef2c2c122ab45152b968910eae68bdee2c4cfae0", 202 | "sha256:329a6d8b6d36f7d6f8b6c6a1db3b2c40f7e30a19d3caf62023c9d6a677c1b5e1", 203 | "sha256:423cde55430a348bda6f1021faad7235c2a95a6bdb749e34824e5758f755817a", 204 | "sha256:4651ea05939374cfb5fe87aab5271ed38c31ea47997e17ec3834b75b94bd9f15", 205 | "sha256:4be3bbfb2968d7da6e5c2cd4104fc5ec1caf9c0794f6cae724da5a53b4d9f5a3", 206 | "sha256:622f7e40faef13d232fb52003661f2764ce6cdef3edb0a59af7c1559e4cc36d1", 207 | "sha256:664dfd4384d886b239ef0d7ee5cff2b463831079d250528b10e394a322f141f9", 208 | "sha256:697c0f58ac637b11991a1bc92e07c34da4a72e2eda34d317d2c1c47e2f24c1b3", 209 | "sha256:6ec908b4c8a4faa7fe1a0080768e2ce733f268b287dfefb723273fb34141475f", 210 | "sha256:7ec3fe795582b75bb49bb1685ffc462dbe38d74312dac07ce386671a28b5316b", 211 | "sha256:8c39babd923c431dcf1e5874c0f778d3a5c745a62c3a9b6bd755efd489ee8a1d", 212 | "sha256:949ca5bc56d6cb73d956f4862ba06ad3c5d2808eac76304284f53ae0c8b2334a", 213 | "sha256:9f0daddeefb0791a600e6195441910bdf01eac470be596b9467e6122b51239a6", 214 | "sha256:a359893b01c30e949eae0e8a85671a593364c9f0b8162afe0cb97317af0953bf", 215 | "sha256:ad5d5d8efed59e6b1d4c50c1eac59fb6ecec91b2073676af1e15fc4d43e9b6c5", 216 | "sha256:bc1a36f95a6b3667c09b34995fc3a46a82e4cf0dc3e7ab281e4c77b15bd7af05", 217 | "sha256:be37b3f55b6d7d923f43bf74c356fc1878eb36e28505f38e198cb432c19c7b1a", 218 | "sha256:c45bca5e544eb75f7500ffd730df72922eb878a2f0213b0dc5a5f357ded3a85d", 219 | "sha256:ccee7ebbb4735ebc341d347fca9ee09f2fa6c0580528c1414bc4e1d31372835c", 220 | "sha256:dc62c0840b2fc7753550b40405532a3e125c0d3761f34af948873393aa688160", 221 | "sha256:f7d9d5aa1c7e54167f1a3cba36b5c52c7c540f30952c9bd7d9302a1eda318424" 222 | ], 223 | "version": "==4.2.3" 224 | }, 225 | "parsedatetime": { 226 | "hashes": [ 227 | "sha256:3d817c58fb9570d1eec1dd46fa9448cd644eeed4fb612684b02dfda3a79cb84b", 228 | "sha256:9ee3529454bf35c40a77115f5a596771e59e1aee8c53306f346c461b8e913094" 229 | ], 230 | "index": "pypi", 231 | "version": "==2.4" 232 | }, 233 | "parsel": { 234 | "hashes": [ 235 | "sha256:a4d581260eb845a762b9a354b0fc5e1c5c42df009dc8163c181097bd5314db58", 236 | "sha256:b24618fe81dce29d717aa8c4a9534c46e807dd6a5c8d5e1bb3b1fdb3fbd22b56" 237 | ], 238 | "markers": "python_version != '3.3.*' and python_version != '3.0.*' and python_version != '3.2.*' and python_version >= '2.7' and python_version != '3.1.*'", 239 | "version": "==1.5.0" 240 | }, 241 | "pyasn1": { 242 | "hashes": [ 243 | "sha256:a66dcda18dbf6e4663bde70eb30af3fc4fe1acb2d14c4867a861681887a5f9a2", 244 | "sha256:fb81622d8f3509f0026b0683fe90fea27be7284d3826a5f2edf97f69151ab0fc" 245 | ], 246 | "version": "==0.4.3" 247 | }, 248 | "pyasn1-modules": { 249 | "hashes": [ 250 | "sha256:a0cf3e1842e7c60fde97cb22d275eb6f9524f5c5250489e292529de841417547", 251 | "sha256:a38a8811ea784c0136abfdba73963876328f66172db21a05a82f9515909bfb4e" 252 | ], 253 | "version": "==0.2.2" 254 | }, 255 | "pycparser": { 256 | "hashes": [ 257 | "sha256:99a8ca03e29851d96616ad0404b4aad7d9ee16f25c9f9708a11faf2810f7b226" 258 | ], 259 | "version": "==2.18" 260 | }, 261 | "pydispatcher": { 262 | "hashes": [ 263 | "sha256:5570069e1b1769af1fe481de6dd1d3a388492acddd2cdad7a3bde145615d5caf", 264 | "sha256:5be4a8be12805ef7d712dd9a93284fb8bc53f309867e573f653a72e5fd10e433" 265 | ], 266 | "version": "==2.0.5" 267 | }, 268 | "pyopenssl": { 269 | "hashes": [ 270 | "sha256:26ff56a6b5ecaf3a2a59f132681e2a80afcc76b4f902f612f518f92c2a1bf854", 271 | "sha256:6488f1423b00f73b7ad5167885312bb0ce410d3312eb212393795b53c8caa580" 272 | ], 273 | "version": "==18.0.0" 274 | }, 275 | "python-dateutil": { 276 | "hashes": [ 277 | "sha256:1adb80e7a782c12e52ef9a8182bebeb73f1d7e24e374397af06fb4956c8dc5c0", 278 | "sha256:e27001de32f627c22380a688bcc43ce83504a7bc5da472209b4c70f02829f0b8" 279 | ], 280 | "version": "==2.7.3" 281 | }, 282 | "pytz": { 283 | "hashes": [ 284 | "sha256:a061aa0a9e06881eb8b3b2b43f05b9439d6583c206d0a6c340ff72a7b6669053", 285 | "sha256:ffb9ef1de172603304d9d2819af6f5ece76f2e85ec10692a524dd876e72bf277" 286 | ], 287 | "index": "pypi", 288 | "version": "==2018.5" 289 | }, 290 | "queuelib": { 291 | "hashes": [ 292 | "sha256:42b413295551bdc24ed9376c1a2cd7d0b1b0fa4746b77b27ca2b797a276a1a17", 293 | "sha256:ff43b5b74b9266f8df4232a8f768dc4d67281a271905e2ed4a3689d4d304cd02" 294 | ], 295 | "version": "==1.5.0" 296 | }, 297 | "raven": { 298 | "hashes": [ 299 | "sha256:3fd787d19ebb49919268f06f19310e8112d619ef364f7989246fc8753d469888", 300 | "sha256:95f44f3ea2c1b176d5450df4becdb96c15bf2632888f9ab193e9dd22300ce46a" 301 | ], 302 | "index": "pypi", 303 | "version": "==6.9.0" 304 | }, 305 | "requests": { 306 | "hashes": [ 307 | "sha256:63b52e3c866428a224f97cab011de738c36aec0185aa91cfacd418b5d58911d1", 308 | "sha256:ec22d826a36ed72a7358ff3fe56cbd4ba69dd7a6718ffd450ff0e9df7a47ce6a" 309 | ], 310 | "index": "pypi", 311 | "version": "==2.19.1" 312 | }, 313 | "schedule": { 314 | "hashes": [ 315 | "sha256:1003a07c2dce12828c25a03a611a7371cedfa956e5f1b4abc32bcc94eb5a335b", 316 | "sha256:a24e75fc5e5acbd204049d55329e39a2a9a3479bca2e34c7fde81386c9d8d2fa" 317 | ], 318 | "index": "pypi", 319 | "version": "==0.5.0" 320 | }, 321 | "scrapy": { 322 | "hashes": [ 323 | "sha256:08d86737c560dcc1c4b73ac0ac5bd8d14b3e2265c1f7b195f0b73ab13741fe03", 324 | "sha256:31a0bf05d43198afaf3acfb9b4fb0c09c1d7d7ff641e58c66e36117f26c4b755" 325 | ], 326 | "index": "pypi", 327 | "version": "==1.5.0" 328 | }, 329 | "service-identity": { 330 | "hashes": [ 331 | "sha256:0e76f3c042cc0f5c7e6da002cf646f59dc4023962d1d1166343ce53bdad39e17", 332 | "sha256:4001fbb3da19e0df22c47a06d29681a398473af4aa9d745eca525b3b2c2302ab" 333 | ], 334 | "version": "==17.0.0" 335 | }, 336 | "six": { 337 | "hashes": [ 338 | "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", 339 | "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb" 340 | ], 341 | "version": "==1.11.0" 342 | }, 343 | "twisted": { 344 | "git": "https://github.com/twisted/twisted.git", 345 | "ref": "ef0f83b41702d156d0b320c8f0cdb2797b763d9d" 346 | }, 347 | "urllib3": { 348 | "hashes": [ 349 | "sha256:a68ac5e15e76e7e5dd2b8f94007233e01effe3e50e8daddf69acfd81cb686baf", 350 | "sha256:b5725a0bd4ba422ab0e66e89e030c806576753ea3ee08554382c14e685d117b5" 351 | ], 352 | "markers": "python_version != '3.0.*' and python_version != '3.1.*' and python_version != '3.2.*' and python_version >= '2.6' and python_version != '3.3.*' and python_version < '4'", 353 | "version": "==1.23" 354 | }, 355 | "w3lib": { 356 | "hashes": [ 357 | "sha256:55994787e93b411c2d659068b51b9998d9d0c05e0df188e6daf8f45836e1ea38", 358 | "sha256:aaf7362464532b1036ab0092e2eee78e8fd7b56787baa9ed4967457b083d011b" 359 | ], 360 | "version": "==1.19.0" 361 | }, 362 | "webencodings": { 363 | "hashes": [ 364 | "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", 365 | "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923" 366 | ], 367 | "version": "==0.5.1" 368 | }, 369 | "zope.interface": { 370 | "hashes": [ 371 | "sha256:21506674d30c009271fe68a242d330c83b1b9d76d62d03d87e1e9528c61beea6", 372 | "sha256:3d184aff0756c44fff7de69eb4cd5b5311b6f452d4de28cb08343b3f21993763", 373 | "sha256:467d364b24cb398f76ad5e90398d71b9325eb4232be9e8a50d6a3b3c7a1c8789", 374 | "sha256:57c38470d9f57e37afb460c399eb254e7193ac7fb8042bd09bdc001981a9c74c", 375 | "sha256:9ada83f4384bbb12dedc152bcdd46a3ac9f5f7720d43ac3ce3e8e8b91d733c10", 376 | "sha256:a1daf9c5120f3cc6f2b5fef8e1d2a3fb7bbbb20ed4bfdc25bc8364bc62dcf54b", 377 | "sha256:e6b77ae84f2b8502d99a7855fa33334a1eb6159de45626905cb3e454c023f339", 378 | "sha256:e881ef610ff48aece2f4ee2af03d2db1a146dc7c705561bd6089b2356f61641f", 379 | "sha256:f41037260deaacb875db250021fe883bf536bf6414a4fd25b25059b02e31b120" 380 | ], 381 | "markers": "python_version != '3.1.*' and python_version != '3.0.*' and python_version != '3.2.*' and python_version >= '2.7'", 382 | "version": "==4.5.0" 383 | } 384 | }, 385 | "develop": {} 386 | } 387 | -------------------------------------------------------------------------------- /scrapers/Procfile: -------------------------------------------------------------------------------- 1 | worker: python server.py -------------------------------------------------------------------------------- /scrapers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codebutler/59boards/fc7255aac18d67e08b4ae20c671540a6f80dc6e3/scrapers/__init__.py -------------------------------------------------------------------------------- /scrapers/cbmap/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codebutler/59boards/fc7255aac18d67e08b4ae20c671540a6f80dc6e3/scrapers/cbmap/__init__.py -------------------------------------------------------------------------------- /scrapers/cbmap/items.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Define here the models for your scraped items 4 | # 5 | # See documentation in: 6 | # https://doc.scrapy.org/en/latest/topics/items.html 7 | 8 | import scrapy 9 | from scrapy import Field 10 | 11 | 12 | class CalEventItem(scrapy.Item): 13 | id = Field() 14 | date = Field() 15 | summary = Field() 16 | location = Field() 17 | description = Field() 18 | -------------------------------------------------------------------------------- /scrapers/cbmap/jwtauth.py: -------------------------------------------------------------------------------- 1 | from scrapy import signals 2 | 3 | 4 | class JWTAuthMiddleware(object): 5 | 6 | auth = None 7 | 8 | @classmethod 9 | def from_crawler(cls, crawler): 10 | o = cls() 11 | crawler.signals.connect(o.spider_opened, signal=signals.spider_opened) 12 | return o 13 | 14 | def spider_opened(self, spider): 15 | jwt = getattr(spider, 'jwt', '') 16 | if jwt: 17 | self.auth = f'Bearer {jwt}' 18 | 19 | def process_request(self, request, spider): 20 | auth = getattr(self, 'auth', None) 21 | if auth and b'Authorization' not in request.headers: 22 | request.headers[b'Authorization'] = auth 23 | -------------------------------------------------------------------------------- /scrapers/cbmap/pipelines.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Define your item pipelines here 4 | # 5 | # Don't forget to add your pipeline to the ITEM_PIPELINES setting 6 | # See: https://doc.scrapy.org/en/latest/topics/item-pipeline.html 7 | import hashlib 8 | import json 9 | from datetime import timedelta, datetime 10 | from typing import IO 11 | 12 | import os 13 | from icalendar import Calendar, Event 14 | from scrapy.exporters import JsonItemExporter 15 | 16 | from cbmap.items import CalEventItem 17 | from cbmap.serialize import json_default 18 | 19 | OUTPUT_DIR = os.path.join(os.path.realpath(os.path.dirname(__file__)), '../output') 20 | 21 | 22 | class IdPipeline(object): 23 | def process_item(self, item, spider): 24 | if 'id' not in item: 25 | item['id'] = hashlib.md5(json.dumps(dict(item), default=json_default).encode('utf-8')).hexdigest() 26 | return item 27 | 28 | 29 | class MissingSummaryPipeline(object): 30 | def process_item(self, item, spider): 31 | if item.get('summary', None) is None: 32 | item['summary'] = f'{spider.title} Meeting' 33 | return item 34 | 35 | 36 | class CounterPipeline(object): 37 | count = 0 38 | 39 | def open_spider(self, spider): 40 | self.count = 0 41 | 42 | def process_item(self, item, spider): 43 | self.count = self.count + 1 44 | 45 | def close_spider(self, spider): 46 | if self.count == 0: 47 | raise Exception(f'No events found for spider: {spider.name}') 48 | 49 | 50 | class JsonWriterPipeline(object): 51 | file: IO = None 52 | exporter: JsonItemExporter = None 53 | 54 | def open_spider(self, spider): 55 | os.makedirs(OUTPUT_DIR, exist_ok=True) 56 | self.file = open(os.path.join(OUTPUT_DIR, f'{spider.name}.json'), 'wb') 57 | self.exporter = JsonItemExporter( 58 | self.file, 59 | encoding='utf-8', 60 | indent=2, 61 | default=json_default) 62 | self.exporter.start_exporting() 63 | 64 | def process_item(self, item, spider): 65 | self.exporter.export_item(item) 66 | return item 67 | 68 | def close_spider(self, spider): 69 | self.exporter.finish_exporting() 70 | self.file.close() 71 | 72 | 73 | class ICalWriterPipeline(object): 74 | cal: Calendar = None 75 | 76 | def open_spider(self, spider): 77 | self.cal = Calendar() 78 | self.cal.add('X-WR-CALNAME', spider.title) 79 | self.cal.add('summary', spider.title) 80 | self.cal.add('prodid', '-//59Boards//59Boards//EN') 81 | self.cal.add('version', '2.0') 82 | 83 | def process_item(self, item: CalEventItem, spider): 84 | event = Event() 85 | event.add('uid', item['id']) 86 | event.add('summary', self.__add_disclaimer_summary(item['summary'])) 87 | event.add('description', self.__add_disclaimer_description(item['description'])) 88 | event.add('location', item['location']) 89 | event.add('dtstamp', datetime.utcnow()) 90 | event.add('dtstart', item['date']) 91 | event.add('dtend', item['date'] + timedelta(hours=2)) 92 | self.cal.add_component(event) 93 | return item 94 | 95 | def close_spider(self, spider): 96 | os.makedirs(OUTPUT_DIR, exist_ok=True) 97 | file = open(os.path.join(OUTPUT_DIR, f'{spider.name}.ics'), 'wb') 98 | file.write(self.cal.to_ical()) 99 | file.close() 100 | 101 | @staticmethod 102 | def __add_disclaimer_summary(text: str) -> str: 103 | return ' '.join((x for x in (text, '[CHECK WEBSITE]') if x)) 104 | 105 | @staticmethod 106 | def __add_disclaimer_description(text: str) -> str: 107 | disclaimer = 'NOTE: This event was automatically generated, check website to verify.' \ 108 | '\nReport issues at https://github.com/codebutler/59boards/issues' 109 | return '\n'.join((x for x in (text, disclaimer) if x)) 110 | 111 | -------------------------------------------------------------------------------- /scrapers/cbmap/serialize.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytz 4 | 5 | DATE_FORMAT = "%Y-%m-%d" 6 | TIME_FORMAT = "%H:%M:%SZ" 7 | 8 | 9 | # https://github.com/scrapy/scrapy/issues/2087 10 | def json_default(obj): 11 | if isinstance(obj, datetime.datetime): 12 | return obj.astimezone(pytz.utc).strftime("%sT%s" % (DATE_FORMAT, TIME_FORMAT)) 13 | elif isinstance(obj, datetime.date): 14 | return obj.strftime("%s" % DATE_FORMAT) 15 | else: 16 | raise TypeError(f"Type {type(obj)} not serializable") 17 | -------------------------------------------------------------------------------- /scrapers/cbmap/settings.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from raven.conf import setup_logging 5 | from raven.handlers.logging import SentryHandler 6 | 7 | BOT_NAME = 'cbmap' 8 | 9 | SPIDER_MODULES = ['cbmap.spiders'] 10 | NEWSPIDER_MODULE = 'cbmap.spiders' 11 | 12 | ROBOTSTXT_OBEY = True 13 | 14 | DOWNLOADER_MIDDLEWARES = { 15 | 'cbmap.jwtauth.JWTAuthMiddleware': 100 16 | } 17 | 18 | ITEM_PIPELINES = { 19 | 'cbmap.pipelines.IdPipeline': 300, 20 | 'cbmap.pipelines.MissingSummaryPipeline': 350, 21 | 'cbmap.pipelines.JsonWriterPipeline': 400, 22 | 'cbmap.pipelines.ICalWriterPipeline': 500, 23 | 'cbmap.pipelines.CounterPipeline': 500, 24 | } 25 | 26 | SENTRY_DSN = os.getenv('SENTRY_DSN', None) 27 | if SENTRY_DSN: 28 | handler = SentryHandler(SENTRY_DSN, level=logging.ERROR) 29 | setup_logging(handler) 30 | -------------------------------------------------------------------------------- /scrapers/cbmap/spiders/__init__.py: -------------------------------------------------------------------------------- 1 | # This package will contain the spiders of your Scrapy project 2 | # 3 | # Please refer to the documentation for information on how to create and manage 4 | # your spiders. 5 | -------------------------------------------------------------------------------- /scrapers/cbmap/spiders/bronx-cb2.py: -------------------------------------------------------------------------------- 1 | import scrapy 2 | from icalendar import Calendar 3 | from pytz import timezone 4 | 5 | from cbmap.items import CalEventItem 6 | 7 | 8 | class BronxCb2Spider(scrapy.Spider): 9 | name = 'bronx-cb2' 10 | title = 'Bronx CB2' 11 | 12 | start_urls = [ 13 | 'http://bxcb2.org/events/?ical=1&tribe_display=month' 14 | ] 15 | 16 | def parse(self, response): 17 | cal = Calendar.from_ical(response.body_as_unicode()) 18 | for vevent in cal.subcomponents: 19 | event_dt = timezone('US/Eastern').localize(vevent.get('DTSTART').dt) 20 | event_id = vevent.get('UID') 21 | event_summary = vevent.get('SUMMARY') 22 | event_location = vevent.get('LOCATION') 23 | event_url = vevent.get('URL') 24 | yield CalEventItem( 25 | id=event_id, 26 | date=event_dt, 27 | summary=event_summary, 28 | description=event_url, 29 | location=event_location 30 | ) 31 | 32 | -------------------------------------------------------------------------------- /scrapers/cbmap/spiders/bronx-cb5.py: -------------------------------------------------------------------------------- 1 | import scrapy 2 | 3 | from cbmap.utils import parse_calendarjs 4 | 5 | 6 | class BrooklynCb1Spider(scrapy.Spider): 7 | name = 'bronx-cb5' 8 | title = 'Bronx CB5' 9 | start_urls = [ 10 | 'http://www1.nyc.gov/assets/bronxcb5/js/calendar_events.js' 11 | ] 12 | 13 | def parse(self, response): 14 | for item in parse_calendarjs(response): 15 | yield item 16 | -------------------------------------------------------------------------------- /scrapers/cbmap/spiders/bronx-cb9.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import scrapy 4 | from bs4 import BeautifulSoup 5 | 6 | from cbmap.items import CalEventItem 7 | 8 | 9 | class BronxCb9Spider(scrapy.Spider): 10 | name = 'bronx-cb9' 11 | title = 'Bronx CB9' 12 | start_urls = [ 13 | 'http://www1.nyc.gov/site/bronxcb9/calendar/calendar.page' 14 | ] 15 | 16 | def parse(self, response): 17 | soup = BeautifulSoup(response.text, 'lxml') 18 | list_items = soup.select('.about-description li') 19 | for li in list_items: 20 | title_elm = li.select_one('b') 21 | 22 | summary = ''.join(title_elm.stripped_strings) 23 | datetime_text = ''.join(title_elm.find_previous_siblings(text=True)).strip() 24 | location = ''.join(title_elm.find_next_siblings(text=True)).strip() 25 | 26 | if '-' in datetime_text: 27 | datetime_text = datetime_text.split('-')[0].strip() 28 | 29 | event_dt = datetime.strptime(datetime_text, '%A, %B %d, %I%p').replace(year=datetime.now().year) 30 | 31 | yield CalEventItem( 32 | date=event_dt, 33 | summary=summary, 34 | description=None, 35 | location=location 36 | ) 37 | -------------------------------------------------------------------------------- /scrapers/cbmap/spiders/brooklyn-cb1.py: -------------------------------------------------------------------------------- 1 | import scrapy 2 | 3 | from cbmap.utils import parse_calendarjs 4 | 5 | 6 | class BrooklynCb1Spider(scrapy.Spider): 7 | name = 'brooklyn-cb1' 8 | title = 'Queens CB1' 9 | start_urls = [ 10 | 'http://www.nyc.gov/html/bkncb1/includes/scripts/calendar.js' 11 | ] 12 | 13 | def parse(self, response): 14 | for item in parse_calendarjs(response): 15 | yield item 16 | -------------------------------------------------------------------------------- /scrapers/cbmap/spiders/brooklyn-cb2.py: -------------------------------------------------------------------------------- 1 | import calendar 2 | import re 3 | from datetime import datetime 4 | 5 | import scrapy 6 | from bs4 import BeautifulSoup 7 | from bs4 import Tag 8 | from bs4.element import NavigableString 9 | 10 | from cbmap.items import CalEventItem 11 | 12 | DAY_NAMES = list(calendar.day_name) 13 | MONTH_NAMES = list(calendar.month_name)[1:] 14 | 15 | 16 | class BrooklynCb2Spider(scrapy.Spider): 17 | name = 'brooklyn-cb2' 18 | title = 'Brooklyn CB2' 19 | start_urls = [ 20 | 'http://www.nyc.gov/html/bkncb2/html/calendar/calendar.shtml' 21 | ] 22 | 23 | def parse(self, response): 24 | soup = BeautifulSoup(response.text, 'lxml') 25 | tag = soup.select('.highlight_bodytext')[0] # type: Tag 26 | lines = [] 27 | current = "" 28 | 29 | for child in tag.descendants: # type: Tag 30 | if isinstance(child, NavigableString): 31 | current += child.string.strip('\n').replace(u'\xa0', ' ') 32 | if child.name == 'br': 33 | if current: 34 | lines.append(current) 35 | current = "" 36 | if current: 37 | lines.append(current) 38 | 39 | lines = [line.strip() for line in lines if line.strip()] 40 | 41 | current_month = None 42 | current_day = None 43 | current_year = None 44 | text_buffer = [] 45 | 46 | for index, text in enumerate(lines): 47 | tokens = re.split(r'\W+', text) 48 | is_date = tokens[0] in DAY_NAMES and tokens[1] in MONTH_NAMES and tokens[2].isdigit() 49 | is_year = tokens[0] in MONTH_NAMES and tokens[1].isdigit() 50 | is_last = index == len(lines) - 1 51 | if is_date: 52 | current_month = MONTH_NAMES.index(tokens[1]) + 1 53 | current_day = int(tokens[2]) 54 | elif is_year: 55 | current_year = int(tokens[1]) 56 | else: 57 | text_buffer.append(text) 58 | if (is_date or is_year or is_last) and text_buffer: 59 | summary = text_buffer[0] 60 | location = text_buffer[1] if len(text_buffer) > 1 else None 61 | yield CalEventItem( 62 | date=datetime(year=current_year, month=current_month, day=current_day), 63 | summary=summary, 64 | description=None, 65 | location=location 66 | ) 67 | text_buffer = [] 68 | 69 | 70 | -------------------------------------------------------------------------------- /scrapers/cbmap/spiders/brooklyn-cb3.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import urllib.parse 4 | from datetime import datetime 5 | 6 | import scrapy 7 | 8 | from cbmap.items import CalEventItem 9 | 10 | DEV_TABULA_URL = 'http://localhost:4000/scrape' 11 | DEV_TABULA_TOKEN = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0YWJ1bGFzZXJ2ZXIiLCJpYX' \ 12 | 'QiOjE1MjEwNTkyNTUsImV4cCI6MTU1MjU5NTI2NiwiYXVkIjoidGFidWxhc2VydmVyIiwic' \ 13 | '3ViIjoidGFidWxhc2VydmVyIn0.d_3_pDKA4ihwriTkueA_u5CDuntndY1Vqly37jVJXcU' 14 | 15 | 16 | class BrooklynCb3Spider(scrapy.Spider): 17 | name = 'brooklyn-cb3' 18 | title = 'Brooklyn CB3' 19 | 20 | tabula_url = os.environ.get('TABULA_URL', DEV_TABULA_URL) 21 | jwt = os.environ.get('TABULA_TOKEN', DEV_TABULA_TOKEN) 22 | 23 | pdf_url = 'http://www1.nyc.gov/assets/brooklyncb3/downloads/pdf/committee_meetings_2018_calendar.pdf' 24 | 25 | start_urls = [ 26 | f'{tabula_url}/?{urllib.parse.urlencode({"url":pdf_url})}' 27 | ] 28 | 29 | def parse(self, response): 30 | pdf = json.loads(response.body_as_unicode()) 31 | 32 | data = pdf['pages'][0]['tables'][0]['data'] 33 | for event in data: 34 | committee = event['COMMITTEE'] 35 | chair = event['CHAIR/VICE-CHAIR/CO-CHAIR'] 36 | time = event['TIME'] 37 | date = event['DATE'] 38 | 39 | event_time = datetime.strptime(time, '%I:%M %p').time() 40 | event_date = datetime.strptime(date, '%d-%b-%y').date() 41 | event_dt = datetime.combine(event_date, event_time) 42 | 43 | yield CalEventItem( 44 | date=event_dt, 45 | summary=committee, 46 | description=chair, 47 | location=None 48 | ) 49 | 50 | -------------------------------------------------------------------------------- /scrapers/cbmap/spiders/brooklyn-cb6.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import scrapy 4 | from bs4 import BeautifulSoup 5 | from pytz import timezone 6 | 7 | from cbmap import utils 8 | from cbmap.items import CalEventItem 9 | 10 | 11 | class BrooklynCb6Spider(scrapy.Spider): 12 | name = 'brooklyn-cb6' 13 | title = 'Brooklyn CB6' 14 | start_urls = [ 15 | 'http://www1.nyc.gov/site/brooklyncb6/calendar/calendar.page' 16 | ] 17 | 18 | def parse(self, response): 19 | soup = BeautifulSoup(response.text, 'lxml') 20 | 21 | for tag in soup.select('.about-description > h3'): 22 | # Find index of next event header. 23 | all_siblings = tag.select('~ *') 24 | next_header = tag.select_one('~ hr, ~ h3, ~ h2') 25 | 26 | # Find all siblings up to next header (or end of document if last event). 27 | event_tags = tag.select('~ *', limit=all_siblings.index(next_header)) \ 28 | if next_header else all_siblings 29 | 30 | # Use

text as date. 31 | event_date = self.__parse_date(tag.string) 32 | 33 | # Find first

sibling. 34 | event_summary = next((t.string for t in event_tags if t.name == 'h4'), None) 35 | 36 | # If not found, look for first

sibling where all children are . 37 | if not event_summary: 38 | event_summary = next((t.string for t in event_tags 39 | if t.name == 'p' and t.string and all(c.name == 'b' for c in t.children)), None) 40 | 41 | # Find first

sibling where all children are not . 42 | event_location = next((', '.join(t.stripped_strings) for t in event_tags 43 | if t.name == 'p' and all(c.name != 'b' for c in t.children)), None) 44 | 45 | # Find first