From f7712b6b006f10d76255a9b8c7b973b8de855752 Mon Sep 17 00:00:00 2001 From: Jake Mensch Date: Tue, 10 Mar 2020 14:27:47 -0700 Subject: [PATCH 01/10] minor fixes to Menu, Footer, and Criteria --- src/components/Visualizations/Criteria.jsx | 2 +- src/components/main/footer/Footer.jsx | 4 ++-- src/components/main/menu/Menu.jsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/Visualizations/Criteria.jsx b/src/components/Visualizations/Criteria.jsx index acd7e442d..5b59073b4 100644 --- a/src/components/Visualizations/Criteria.jsx +++ b/src/components/Visualizations/Criteria.jsx @@ -47,7 +47,7 @@ export default connect(mapStateToProps)(Criteria); Criteria.propTypes = { startDate: PropTypes.string, endDate: PropTypes.string, - councils: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + councils: PropTypes.arrayOf(PropTypes.string).isRequired, }; Criteria.defaultProps = { diff --git a/src/components/main/footer/Footer.jsx b/src/components/main/footer/Footer.jsx index 5ff72142b..6d0accdeb 100644 --- a/src/components/main/footer/Footer.jsx +++ b/src/components/main/footer/Footer.jsx @@ -30,7 +30,7 @@ const Footer = ({

Data Updated Through:   - {lastUpdated && moment(lastUpdated).format('MMMM Do YYYY, h:mm:ss a')} + {lastUpdated && moment(1000 * lastUpdated).format('MMMM Do YYYY, h:mm:ss a')}

@@ -42,7 +42,7 @@ const mapStateToProps = state => ({ }); Footer.propTypes = { - lastUpdated: propTypes.string, + lastUpdated: propTypes.number, }; Footer.defaultProps = { diff --git a/src/components/main/menu/Menu.jsx b/src/components/main/menu/Menu.jsx index 11ec99dc7..86cc6d306 100644 --- a/src/components/main/menu/Menu.jsx +++ b/src/components/main/menu/Menu.jsx @@ -36,7 +36,7 @@ const Menu = ({ setMenuTab(tab)} + onClick={tab === activeTab ? undefined : () => setMenuTab(tab)} > { tab } From 9fac27b5370bf61f17295986212bcbc4ce857887 Mon Sep 17 00:00:00 2001 From: sellnat77 Date: Tue, 10 Mar 2020 21:56:34 -0700 Subject: [PATCH 02/10] Including request type in pins response --- server/src/services/pinService.py | 1 + 1 file changed, 1 insertion(+) diff --git a/server/src/services/pinService.py b/server/src/services/pinService.py index ae92dda02..3353568df 100644 --- a/server/src/services/pinService.py +++ b/server/src/services/pinService.py @@ -25,6 +25,7 @@ async def get_base_pins(self, """ items = ['srnumber', + 'requesttype', 'latitude', 'longitude'] From 21bb1f7b7fb065512a663d772a864836939ea3c3 Mon Sep 17 00:00:00 2001 From: Jake Mensch Date: Tue, 10 Mar 2020 23:07:48 -0700 Subject: [PATCH 03/10] moved filters into their own reducer --- src/components/Visualizations/Contact311.jsx | 2 +- src/components/Visualizations/Criteria.jsx | 6 +- src/components/Visualizations/Frequency.jsx | 2 +- src/components/Visualizations/Legend.jsx | 2 +- src/components/Visualizations/TimeToClose.jsx | 2 +- .../Visualizations/TotalRequests.jsx | 2 +- .../Visualizations/TypeOfRequest.jsx | 2 +- .../menu/DateSelector/DateRangePicker.jsx | 2 +- .../main/menu/DateSelector/DateSelector.jsx | 6 +- src/components/main/menu/NCSelector.jsx | 2 +- .../main/menu/RequestTypeSelector.jsx | 4 +- src/redux/reducers/data.js | 102 ---------------- src/redux/reducers/filters.js | 114 ++++++++++++++++++ src/redux/rootReducer.js | 2 + src/redux/rootSaga.js | 2 +- 15 files changed, 133 insertions(+), 119 deletions(-) create mode 100644 src/redux/reducers/filters.js diff --git a/src/components/Visualizations/Contact311.jsx b/src/components/Visualizations/Contact311.jsx index bd82b4fbb..310892ead 100644 --- a/src/components/Visualizations/Contact311.jsx +++ b/src/components/Visualizations/Contact311.jsx @@ -85,7 +85,7 @@ const Contact311 = () => { }; const mapStateToProps = state => ({ - requestTypes: state.data.requestTypes, + requestTypes: state.filters.requestTypes, }); export default connect(mapStateToProps)(Contact311); diff --git a/src/components/Visualizations/Criteria.jsx b/src/components/Visualizations/Criteria.jsx index 5b59073b4..0b5a8d721 100644 --- a/src/components/Visualizations/Criteria.jsx +++ b/src/components/Visualizations/Criteria.jsx @@ -37,9 +37,9 @@ const Criteria = ({ }; const mapStateToProps = state => ({ - startDate: state.data.startDate, - endDate: state.data.endDate, - councils: state.data.councils, + startDate: state.filters.startDate, + endDate: state.filters.endDate, + councils: state.filters.councils, }); export default connect(mapStateToProps)(Criteria); diff --git a/src/components/Visualizations/Frequency.jsx b/src/components/Visualizations/Frequency.jsx index c17c4ca02..cd631dbbb 100644 --- a/src/components/Visualizations/Frequency.jsx +++ b/src/components/Visualizations/Frequency.jsx @@ -82,7 +82,7 @@ const Frequency = ({ }; const mapStateToProps = state => ({ - requestTypes: state.data.requestTypes, + requestTypes: state.filters.requestTypes, }); export default connect(mapStateToProps)(Frequency); diff --git a/src/components/Visualizations/Legend.jsx b/src/components/Visualizations/Legend.jsx index 451f4a613..6fcce3b07 100644 --- a/src/components/Visualizations/Legend.jsx +++ b/src/components/Visualizations/Legend.jsx @@ -42,7 +42,7 @@ const Legend = ({ }; const mapStateToProps = state => ({ - requestTypes: state.data.requestTypes, + requestTypes: state.filters.requestTypes, }); export default connect(mapStateToProps)(Legend); diff --git a/src/components/Visualizations/TimeToClose.jsx b/src/components/Visualizations/TimeToClose.jsx index 715d5fa17..8912e777b 100644 --- a/src/components/Visualizations/TimeToClose.jsx +++ b/src/components/Visualizations/TimeToClose.jsx @@ -78,7 +78,7 @@ const TimeToClose = ({ }; const mapStateToProps = state => ({ - requestTypes: state.data.requestTypes, + requestTypes: state.filters.requestTypes, }); export default connect(mapStateToProps)(TimeToClose); diff --git a/src/components/Visualizations/TotalRequests.jsx b/src/components/Visualizations/TotalRequests.jsx index be3c44b0d..3769a0797 100644 --- a/src/components/Visualizations/TotalRequests.jsx +++ b/src/components/Visualizations/TotalRequests.jsx @@ -87,7 +87,7 @@ const TotalRequests = ({ }; const mapStateToProps = state => ({ - requestTypes: state.data.requestTypes, + requestTypes: state.filters.requestTypes, }); export default connect(mapStateToProps)(TotalRequests); diff --git a/src/components/Visualizations/TypeOfRequest.jsx b/src/components/Visualizations/TypeOfRequest.jsx index 37d6dd1fb..d27d5448e 100644 --- a/src/components/Visualizations/TypeOfRequest.jsx +++ b/src/components/Visualizations/TypeOfRequest.jsx @@ -62,7 +62,7 @@ const TypeOfRequest = ({ }; const mapStateToProps = state => ({ - requestTypes: state.data.requestTypes, + requestTypes: state.filters.requestTypes, }); export default connect(mapStateToProps)(TypeOfRequest); diff --git a/src/components/main/menu/DateSelector/DateRangePicker.jsx b/src/components/main/menu/DateSelector/DateRangePicker.jsx index 72674b74f..5219657f3 100644 --- a/src/components/main/menu/DateSelector/DateRangePicker.jsx +++ b/src/components/main/menu/DateSelector/DateRangePicker.jsx @@ -7,7 +7,7 @@ import DatePicker from 'react-datepicker'; import { updateStartDate, updateEndDate, -} from '../../../../redux/reducers/data'; +} from '../../../../redux/reducers/filters'; import Button from '../../../common/Button'; import Icon from '../../../common/Icon'; diff --git a/src/components/main/menu/DateSelector/DateSelector.jsx b/src/components/main/menu/DateSelector/DateSelector.jsx index d22eac842..0607bed7c 100644 --- a/src/components/main/menu/DateSelector/DateSelector.jsx +++ b/src/components/main/menu/DateSelector/DateSelector.jsx @@ -5,7 +5,7 @@ import moment from 'moment'; import { updateStartDate, updateEndDate, -} from '../../../../redux/reducers/data'; +} from '../../../../redux/reducers/filters'; import Dropdown from '../../../common/Dropdown'; import Modal from '../../../common/Modal'; @@ -145,8 +145,8 @@ const DateSelector = ({ }; const mapStateToProps = state => ({ - startDate: state.data.startDate, - endDate: state.data.endDate, + startDate: state.filters.startDate, + endDate: state.filters.endDate, }); const mapDispatchToProps = dispatch => ({ diff --git a/src/components/main/menu/NCSelector.jsx b/src/components/main/menu/NCSelector.jsx index d8857e878..c717a014a 100644 --- a/src/components/main/menu/NCSelector.jsx +++ b/src/components/main/menu/NCSelector.jsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { connect } from 'react-redux'; import propTypes from 'proptypes'; -import { updateNC } from '../../../redux/reducers/data'; +import { updateNC } from '../../../redux/reducers/filters'; import { COUNCILS } from '../../common/CONSTANTS'; import Checkbox from '../../common/Checkbox'; diff --git a/src/components/main/menu/RequestTypeSelector.jsx b/src/components/main/menu/RequestTypeSelector.jsx index 096a90548..58c901bd1 100644 --- a/src/components/main/menu/RequestTypeSelector.jsx +++ b/src/components/main/menu/RequestTypeSelector.jsx @@ -5,7 +5,7 @@ import { updateRequestType, selectAllRequestTypes, deselectAllRequestTypes, -} from '../../../redux/reducers/data'; +} from '../../../redux/reducers/filters'; import Checkbox from '../../common/Checkbox'; import Icon from '../../common/Icon'; @@ -150,7 +150,7 @@ const RequestTypeSelector = ({ }; const mapStateToProps = state => ({ - requestTypes: state.data.requestTypes, + requestTypes: state.filters.requestTypes, }); const mapDispatchToProps = dispatch => ({ diff --git a/src/redux/reducers/data.js b/src/redux/reducers/data.js index e068239cf..92c437b6f 100644 --- a/src/redux/reducers/data.js +++ b/src/redux/reducers/data.js @@ -1,43 +1,9 @@ export const types = { - UPDATE_START_DATE: 'UPDATE_START_DATE', - UPDATE_END_DATE: 'UPDATE_END_DATE', - UPDATE_REQUEST_TYPE: 'UPDATE_REQUEST_TYPE', - UPDATE_NEIGHBORHOOD_COUNCIL: 'UPDATE_NEIGHBORHOOD_COUNCIL', GET_DATA_REQUEST: 'GET_DATA_REQUEST', GET_DATA_SUCCESS: 'GET_DATA_SUCCESS', GET_DATA_FAILURE: 'GET_DATA_FAILURE', - SELECT_ALL_REQUEST_TYPES: 'SELECT_ALL_REQUEST_TYPES', - DESELECT_ALL_REQUEST_TYPES: 'DESELECT_ALL_REQUEST_TYPES', }; -export const updateStartDate = newStartDate => ({ - type: types.UPDATE_START_DATE, - payload: newStartDate, -}); - -export const updateEndDate = newEndDate => ({ - type: types.UPDATE_END_DATE, - payload: newEndDate, -}); - -export const updateRequestType = requestTypes => ({ - type: types.UPDATE_REQUEST_TYPE, - payload: requestTypes, -}); - -export const selectAllRequestTypes = () => ({ - type: types.SELECT_ALL_REQUEST_TYPES, -}); - -export const deselectAllRequestTypes = () => ({ - type: types.DESELECT_ALL_REQUEST_TYPES, -}); - -export const updateNC = council => ({ - type: types.UPDATE_NEIGHBORHOOD_COUNCIL, - payload: council, -}); - export const getDataRequest = () => ({ type: types.GET_DATA_REQUEST, }); @@ -53,82 +19,14 @@ export const getDataFailure = error => ({ }); const initialState = { - startDate: null, - endDate: null, - councils: [], data: [], isLoading: false, error: null, lastUpdated: null, - requestTypes: { - All: false, - 'Dead Animal': false, - 'Homeless Encampment': false, - 'Single Streetlight': false, - 'Multiple Streetlight': false, - 'Bulky Items': false, - 'E-Waste': false, - 'Metal/Household Appliances': false, - 'Illegal Dumping': false, - Graffiti: false, - Feedback: false, - Other: false, - }, -}; - -const allRequestTypes = { - All: true, - 'Dead Animal': true, - 'Homeless Encampment': true, - 'Single Streetlight': true, - 'Multiple Streetlight': true, - 'Bulky Items': true, - 'E-Waste': true, - 'Metal/Household Appliances': true, - 'Illegal Dumping': true, - Graffiti: true, - Feedback: true, - Other: true, }; export default (state = initialState, action) => { switch (action.type) { - case types.UPDATE_START_DATE: { - return { - ...state, - startDate: action.payload, - }; - } - case types.UPDATE_END_DATE: { - return { - ...state, - endDate: action.payload, - }; - } - case types.UPDATE_REQUEST_TYPE: - return { - ...state, - requestTypes: { - ...state.requestTypes, - // Flips boolean value for selected request type - [action.payload]: !state.requestTypes[action.payload], - }, - }; - case types.SELECT_ALL_REQUEST_TYPES: - return { - ...state, - requestTypes: allRequestTypes, - }; - case types.DESELECT_ALL_REQUEST_TYPES: - return { - ...state, - requestTypes: initialState.requestTypes, - }; - case types.UPDATE_NEIGHBORHOOD_COUNCIL: - return { - ...state, - councils: action.payload, - }; case types.GET_DATA_REQUEST: return { ...state, diff --git a/src/redux/reducers/filters.js b/src/redux/reducers/filters.js new file mode 100644 index 000000000..04def95c1 --- /dev/null +++ b/src/redux/reducers/filters.js @@ -0,0 +1,114 @@ +export const types = { + UPDATE_START_DATE: 'UPDATE_START_DATE', + UPDATE_END_DATE: 'UPDATE_END_DATE', + UPDATE_REQUEST_TYPE: 'UPDATE_REQUEST_TYPE', + UPDATE_NEIGHBORHOOD_COUNCIL: 'UPDATE_NEIGHBORHOOD_COUNCIL', + SELECT_ALL_REQUEST_TYPES: 'SELECT_ALL_REQUEST_TYPES', + DESELECT_ALL_REQUEST_TYPES: 'DESELECT_ALL_REQUEST_TYPES', +}; + +export const updateStartDate = newStartDate => ({ + type: types.UPDATE_START_DATE, + payload: newStartDate, +}); + +export const updateEndDate = newEndDate => ({ + type: types.UPDATE_END_DATE, + payload: newEndDate, +}); + +export const updateRequestType = requestTypes => ({ + type: types.UPDATE_REQUEST_TYPE, + payload: requestTypes, +}); + +export const selectAllRequestTypes = () => ({ + type: types.SELECT_ALL_REQUEST_TYPES, +}); + +export const deselectAllRequestTypes = () => ({ + type: types.DESELECT_ALL_REQUEST_TYPES, +}); + +export const updateNC = council => ({ + type: types.UPDATE_NEIGHBORHOOD_COUNCIL, + payload: council, +}); + +const initialState = { + startDate: null, + endDate: null, + councils: [], + requestTypes: { + All: false, + 'Dead Animal': false, + 'Homeless Encampment': false, + 'Single Streetlight': false, + 'Multiple Streetlight': false, + 'Bulky Items': false, + 'E-Waste': false, + 'Metal/Household Appliances': false, + 'Illegal Dumping': false, + Graffiti: false, + Feedback: false, + Other: false, + }, +}; + +const allRequestTypes = { + All: true, + 'Dead Animal': true, + 'Homeless Encampment': true, + 'Single Streetlight': true, + 'Multiple Streetlight': true, + 'Bulky Items': true, + 'E-Waste': true, + 'Metal/Household Appliances': true, + 'Illegal Dumping': true, + Graffiti: true, + Feedback: true, + Other: true, +}; + +export default (state = initialState, action) => { + switch (action.type) { + case types.UPDATE_START_DATE: { + return { + ...state, + startDate: action.payload, + }; + } + case types.UPDATE_END_DATE: { + return { + ...state, + endDate: action.payload, + }; + } + case types.UPDATE_REQUEST_TYPE: + return { + ...state, + requestTypes: { + ...state.requestTypes, + // Flips boolean value for selected request type + [action.payload]: !state.requestTypes[action.payload], + }, + }; + case types.SELECT_ALL_REQUEST_TYPES: + return { + ...state, + requestTypes: allRequestTypes, + }; + case types.DESELECT_ALL_REQUEST_TYPES: + return { + ...state, + requestTypes: initialState.requestTypes, + }; + case types.UPDATE_NEIGHBORHOOD_COUNCIL: + return { + ...state, + councils: action.payload, + }; + default: + return state; + } +}; diff --git a/src/redux/rootReducer.js b/src/redux/rootReducer.js index dd4397764..26523f527 100644 --- a/src/redux/rootReducer.js +++ b/src/redux/rootReducer.js @@ -1,8 +1,10 @@ import { combineReducers } from 'redux'; import data from './reducers/data'; +import filters from './reducers/filters'; import ui from './reducers/ui'; export default combineReducers({ data, + filters, ui, }); diff --git a/src/redux/rootSaga.js b/src/redux/rootSaga.js index 671425ed2..140a293b5 100644 --- a/src/redux/rootSaga.js +++ b/src/redux/rootSaga.js @@ -20,7 +20,7 @@ function* getData() { endDate, councils, requestTypes, - } = yield select(getState, 'data'); + } = yield select(getState, 'filters'); const options = { startDate, From 6e60c7ec18ed79a6736b2f142b540a5873e3e72c Mon Sep 17 00:00:00 2001 From: Jake Mensch Date: Wed, 11 Mar 2020 06:52:53 -0700 Subject: [PATCH 04/10] adjusted data reducer and rootSaga to include data for visualizations --- src/components/PinMap/PinMap.jsx | 2 +- src/redux/reducers/data.js | 18 ++++++-- src/redux/rootSaga.js | 75 +++++++++++++++++++++++++++++--- 3 files changed, 86 insertions(+), 9 deletions(-) diff --git a/src/components/PinMap/PinMap.jsx b/src/components/PinMap/PinMap.jsx index 3a55cf5e2..1b28c9564 100644 --- a/src/components/PinMap/PinMap.jsx +++ b/src/components/PinMap/PinMap.jsx @@ -225,7 +225,7 @@ class PinMap extends Component { } const mapStateToProps = state => ({ - data: state.data.data, + data: state.data.pins, }); PinMap.propTypes = { diff --git a/src/redux/reducers/data.js b/src/redux/reducers/data.js index 92c437b6f..a32b54104 100644 --- a/src/redux/reducers/data.js +++ b/src/redux/reducers/data.js @@ -19,10 +19,13 @@ export const getDataFailure = error => ({ }); const initialState = { - data: [], isLoading: false, error: null, lastUpdated: null, + pins: [], + counts: {}, + frequency: {}, + timeToClose: {}, }; export default (state = initialState, action) => { @@ -33,14 +36,23 @@ export default (state = initialState, action) => { isLoading: true, }; case types.GET_DATA_SUCCESS: { - const { data, lastPulled: lastUpdated } = action.payload; + const { + lastUpdated, + pins, + counts, + frequency, + timeToClose, + } = action.payload; return { ...state, - data, error: null, isLoading: false, lastUpdated, + pins, + counts, + frequency, + timeToClose, }; } case types.GET_DATA_FAILURE: { diff --git a/src/redux/rootSaga.js b/src/redux/rootSaga.js index 140a293b5..b650efac0 100644 --- a/src/redux/rootSaga.js +++ b/src/redux/rootSaga.js @@ -11,10 +11,71 @@ import { getDataFailure, } from './reducers/data'; -const pinUrl = `https://${process.env.DB_URL}/pins`; +/* /////////// INDIVIDUAL API CALLS /////////// */ + +const BASE_URL = `https://${process.env.DB_URL}`; + +async function getPins(filters) { + const pinUrl = `${BASE_URL}/pins`; + + const { data: { lastPulled, data } } = await axios.post(pinUrl, filters); + + return { + lastUpdated: lastPulled, + pins: data, + }; +} + +async function getCounts(filters) { + const countsUrl = `${BASE_URL}/requestcounts`; + + const { data: { data } } = await axios.post(countsUrl, { + ...filters, + countFields: ['requesttype', 'requestsource'], + }); + + return { + type: data.find(d => d.field === 'requesttype')?.counts, + source: data.find(d => d.field === 'requestsource')?.counts, + }; +} + +async function getFrequency() { + return {}; +} + +async function getTimeToClose() { + return {}; +} + +/* //////////// COMBINED API CALL //////////// */ + +function getAll(filters) { + return Promise.all([ + getPins(filters), + getCounts(filters), + getFrequency(filters), + getTimeToClose(filters), + ]) + .then(([ + { lastUpdated, pins }, + counts, + frequency, + timeToClose, + ]) => ({ + lastUpdated, + pins, + counts, + frequency, + timeToClose, + })); +} + +/* ////////////////// FILTERS //////////////// */ + const getState = (state, slice) => state[slice]; -function* getData() { +function* getFilters() { const { startDate, endDate, @@ -22,16 +83,20 @@ function* getData() { requestTypes, } = yield select(getState, 'filters'); - const options = { + return { startDate, endDate, ncList: councils, requestTypes: Object.keys(requestTypes).filter(req => req !== 'All' && requestTypes[req]), }; +} + +/* /////////////////// SAGAS ///////////////// */ +function* getData() { + const filters = yield getFilters(); try { - const response = yield call(axios.post, pinUrl, options); - const { data } = response; + const data = yield call(getAll, filters); yield put(getDataSuccess(data)); } catch (e) { yield put(getDataFailure(e)); From a96e6b3fde1659970c0f5398cb3dfadd875940f5 Mon Sep 17 00:00:00 2001 From: Jake Mensch Date: Wed, 11 Mar 2020 06:57:10 -0700 Subject: [PATCH 05/10] removed scheme from BASE_URL to make local dev easier --- src/redux/rootSaga.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/redux/rootSaga.js b/src/redux/rootSaga.js index b650efac0..6f46a14e6 100644 --- a/src/redux/rootSaga.js +++ b/src/redux/rootSaga.js @@ -13,7 +13,7 @@ import { /* /////////// INDIVIDUAL API CALLS /////////// */ -const BASE_URL = `https://${process.env.DB_URL}`; +const BASE_URL = process.env.DB_URL; async function getPins(filters) { const pinUrl = `${BASE_URL}/pins`; From 3344e786758446fb830f9786be3b8d6a4bb2d2ed Mon Sep 17 00:00:00 2001 From: Jake Mensch Date: Wed, 11 Mar 2020 07:01:52 -0700 Subject: [PATCH 06/10] connected NumberOfRequests to real data --- src/components/Visualizations/NumberOfRequests.jsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/Visualizations/NumberOfRequests.jsx b/src/components/Visualizations/NumberOfRequests.jsx index dd84e7c40..4b9e712cd 100644 --- a/src/components/Visualizations/NumberOfRequests.jsx +++ b/src/components/Visualizations/NumberOfRequests.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { connect } from 'react-redux'; import PropTypes from 'proptypes'; function addCommas(num) { @@ -18,12 +19,16 @@ const NumberOfRequests = ({ ); -export default NumberOfRequests; +const mapStateToProps = state => ({ + numRequests: state.data.pins.length, +}); + +export default connect(mapStateToProps)(NumberOfRequests); NumberOfRequests.propTypes = { numRequests: PropTypes.number, }; NumberOfRequests.defaultProps = { - numRequests: 1285203, // until we get data + numRequests: 0, }; From 07f2dfe22a003292b7e6bc3a594c54cf54d3e84c Mon Sep 17 00:00:00 2001 From: Jake Mensch Date: Wed, 11 Mar 2020 07:25:35 -0700 Subject: [PATCH 07/10] connected pie charts to real data --- src/components/Visualizations/Contact311.jsx | 97 ++++------------ src/components/Visualizations/PieChart.jsx | 109 ++++++++++++++++++ .../Visualizations/TypeOfRequest.jsx | 65 +++-------- src/components/common/CONSTANTS.js | 27 +++++ 4 files changed, 171 insertions(+), 127 deletions(-) create mode 100644 src/components/Visualizations/PieChart.jsx diff --git a/src/components/Visualizations/Contact311.jsx b/src/components/Visualizations/Contact311.jsx index 310892ead..3bacb7790 100644 --- a/src/components/Visualizations/Contact311.jsx +++ b/src/components/Visualizations/Contact311.jsx @@ -1,95 +1,38 @@ import React from 'react'; import PropTypes from 'proptypes'; import { connect } from 'react-redux'; -import Chart from './Chart'; - -const Contact311 = () => { - // // DATA //// - - const randomInt = () => { - const min = 10; - const max = 100; - return Math.round(Math.random() * (max - min) + min); - }; - - const dummyData = [{ - label: 'Mobile App', - color: '#1D66F2', - value: randomInt(), - }, { - label: 'Call', - color: '#D8E5FF', - value: randomInt(), - }, { - label: 'Email', - color: '#708ABD', - value: randomInt(), - }, { - label: 'Driver Self Report', - color: '#C4C6C9', - value: randomInt(), - }, { - label: 'Self Service', - color: '#0C2A64', - value: randomInt(), - }, { - label: 'Other', - color: '#6A98F1', - value: randomInt(), - }]; - - const total = dummyData.reduce((p, c) => p + c.value, 0); - - const chartData = { - labels: dummyData.map(el => el.label), - datasets: [{ - data: dummyData.map(el => el.value), - backgroundColor: dummyData.map(el => el.color), - datalabels: { - labels: { - index: { - align: 'end', - anchor: 'end', - formatter: (value, ctx) => { - const { label } = dummyData[ctx.dataIndex]; - const percentage = (100 * (value / total)).toFixed(1); - return `${label}\n${percentage}%`; - }, - offset: 4, - }, - }, - }, - }], - }; - - // // OPTIONS //// - - const chartOptions = { - aspectRatio: 1.0, - animation: false, - layout: { - padding: 65, - }, - }; +import { REQUEST_SOURCES } from '@components/common/CONSTANTS'; +import PieChart from './PieChart'; + +const Contact311 = ({ + sourceCounts, +}) => { + const sectors = Object.keys(sourceCounts).map(key => ({ + label: key, + value: sourceCounts[key], + color: REQUEST_SOURCES.find(s => s.type === key)?.color, + })); return ( - ); }; const mapStateToProps = state => ({ - requestTypes: state.filters.requestTypes, + sourceCounts: state.data.counts.source, }); export default connect(mapStateToProps)(Contact311); Contact311.propTypes = { - requestTypes: PropTypes.shape({}).isRequired, + sourceCounts: PropTypes.shape({}), +}; + +Contact311.defaultProps = { + sourceCounts: {}, }; diff --git a/src/components/Visualizations/PieChart.jsx b/src/components/Visualizations/PieChart.jsx new file mode 100644 index 000000000..4ee14fd22 --- /dev/null +++ b/src/components/Visualizations/PieChart.jsx @@ -0,0 +1,109 @@ +import React from 'react'; +import PropTypes from 'proptypes'; +import Chart from './Chart'; + +const PieChart = ({ + sectors, + title, + className, + addLabels, +}) => { + // // SET ORDER OF SECTORS //// + // This weird code goes a long way towards ensuring that the sectors + // of the pie chart alternate between big and small sectors, so that + // the labels don't overlap each other. It's basically a hack around + // the fact that ChartJS puts the sectors in alphabetical order by label. + + // sort sectors by value + const sortedSectors = [...sectors].sort((a, b) => b.value - a.value); + + // construct sectors that alternate by size + const altSectors = []; + for (let i = 0; i < sectors.length; i += 1) { + altSectors.push(sortedSectors.shift()); + sortedSectors.reverse(); + } + + // add a character to the beginning of each label to trick ChartJS + for (let i = 0; i < altSectors.length; i += 1) { + altSectors[i].label = String.fromCharCode(65 + i) + altSectors[i].label; + } + + // // DATA //// + + const total = altSectors.reduce((p, c) => p + c.value, 0); + + const chartData = { + labels: altSectors.map(el => el.label), + datasets: [{ + data: altSectors.map(el => el.value), + backgroundColor: altSectors.map(el => el.color), + datalabels: { + labels: { + index: { + align: 'end', + anchor: 'end', + formatter: (value, ctx) => { + const { label } = altSectors[ctx.dataIndex]; + const percentage = (100 * (value / total)).toFixed(1); + return addLabels + ? `${label.substring(1)}\n${percentage}%` + : `${percentage}%`; + }, + offset: 4, + }, + }, + }, + }], + }; + + // // OPTIONS //// + + const chartOptions = { + aspectRatio: 1.0, + animation: false, + layout: { + padding: 65, + }, + tooltips: { + callbacks: { + label: (tt, data) => { + const { index } = tt; + const label = data.labels[index].substring(1); + const value = data.datasets[0].data[index]; + return ` ${label}: ${value}`; + }, + }, + }, + }; + + return ( + + ); +}; + +export default PieChart; + +PieChart.propTypes = { + sectors: PropTypes.arrayOf(PropTypes.shape({ + label: PropTypes.string, + value: PropTypes.number, + color: PropTypes.string, + })).isRequired, + title: PropTypes.string, + className: PropTypes.string, + addLabels: PropTypes.bool, +}; + +PieChart.defaultProps = { + title: null, + className: undefined, + addLabels: false, +}; diff --git a/src/components/Visualizations/TypeOfRequest.jsx b/src/components/Visualizations/TypeOfRequest.jsx index d27d5448e..32a0522db 100644 --- a/src/components/Visualizations/TypeOfRequest.jsx +++ b/src/components/Visualizations/TypeOfRequest.jsx @@ -2,71 +2,36 @@ import React from 'react'; import PropTypes from 'proptypes'; import { connect } from 'react-redux'; import { REQUEST_TYPES } from '@components/common/CONSTANTS'; -import Chart from './Chart'; +import PieChart from './PieChart'; const TypeOfRequest = ({ - requestTypes, + typeCounts, }) => { - // // DATA //// - - const randomSeries = (count, min, max) => Array.from({ length: count }) - .map(() => Math.round(Math.random() * (max - min) + min)); - - const selectedTypes = REQUEST_TYPES.filter(el => requestTypes[el.type]); - - const data = randomSeries(selectedTypes.length, 0, 300); - - const total = data.reduce((p, c) => p + c, 0); - - const chartData = { - labels: selectedTypes.map(t => t.abbrev), - datasets: [{ - data, - backgroundColor: selectedTypes.map(t => t.color), - datalabels: { - labels: { - index: { - align: 'end', - anchor: 'end', - formatter: value => { - const percentage = (100 * (value / total)).toFixed(1); - return `${percentage}%`; - }, - offset: 12, - }, - }, - }, - }], - }; - - // // OPTIONS //// - - const chartOptions = { - aspectRatio: 1.0, - animation: false, - layout: { - padding: 65, - }, - }; + const sectors = Object.keys(typeCounts).map(key => ({ + label: key, + value: typeCounts[key], + color: REQUEST_TYPES.find(t => t.type === key)?.color, + })); return ( - ); }; const mapStateToProps = state => ({ - requestTypes: state.filters.requestTypes, + typeCounts: state.data.counts.type, }); export default connect(mapStateToProps)(TypeOfRequest); TypeOfRequest.propTypes = { - requestTypes: PropTypes.shape({}).isRequired, + typeCounts: PropTypes.shape({}), +}; + +TypeOfRequest.defaultProps = { + typeCounts: {}, }; diff --git a/src/components/common/CONSTANTS.js b/src/components/common/CONSTANTS.js index 2c323531a..dd8d05e44 100644 --- a/src/components/common/CONSTANTS.js +++ b/src/components/common/CONSTANTS.js @@ -86,6 +86,33 @@ export const REQUEST_TYPES = [ */ ]; +export const REQUEST_SOURCES = [ + { + type: 'Mobile App', + color: '#1D66F2', + }, + { + type: 'Call', + color: '#D8E5FF', + }, + { + type: 'Email', + color: '#708ABD', + }, + { + type: 'Driver Self Report', + color: '#C4C6C9', + }, + { + type: 'Self Service', + color: '#0C2A64', + }, + { + type: 'Other', + color: '#6A98F1', + }, +]; + export const REQUESTS = [ 'Bulky Items', 'Dead Animal Removal', From 84a30e6348a94ecf4ba1d78a7ae89033ebec93ef Mon Sep 17 00:00:00 2001 From: Jake Mensch Date: Thu, 12 Mar 2020 12:33:06 -0700 Subject: [PATCH 08/10] converted async functions to generators --- src/redux/rootSaga.js | 56 ++++++++++++++++++++++--------------------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/src/redux/rootSaga.js b/src/redux/rootSaga.js index 6f46a14e6..50f6bdf00 100644 --- a/src/redux/rootSaga.js +++ b/src/redux/rootSaga.js @@ -4,6 +4,7 @@ import { call, put, select, + all, } from 'redux-saga/effects'; import { types, @@ -15,10 +16,10 @@ import { const BASE_URL = process.env.DB_URL; -async function getPins(filters) { +function* getPins(filters) { const pinUrl = `${BASE_URL}/pins`; - const { data: { lastPulled, data } } = await axios.post(pinUrl, filters); + const { data: { lastPulled, data } } = yield call(axios.post, pinUrl, filters); return { lastUpdated: lastPulled, @@ -26,10 +27,10 @@ async function getPins(filters) { }; } -async function getCounts(filters) { +function* getCounts(filters) { const countsUrl = `${BASE_URL}/requestcounts`; - const { data: { data } } = await axios.post(countsUrl, { + const { data: { data } } = yield call(axios.post, countsUrl, { ...filters, countFields: ['requesttype', 'requestsource'], }); @@ -40,35 +41,36 @@ async function getCounts(filters) { }; } -async function getFrequency() { - return {}; +function* getFrequency() { + return yield {}; } -async function getTimeToClose() { - return {}; +function* getTimeToClose() { + return yield {}; } /* //////////// COMBINED API CALL //////////// */ -function getAll(filters) { - return Promise.all([ - getPins(filters), - getCounts(filters), - getFrequency(filters), - getTimeToClose(filters), - ]) - .then(([ - { lastUpdated, pins }, - counts, - frequency, - timeToClose, - ]) => ({ - lastUpdated, - pins, - counts, - frequency, - timeToClose, - })); +function* getAll(filters) { + const [ + { lastUpdated, pins }, + counts, + frequency, + timeToClose, + ] = yield all([ + call(getPins, filters), + call(getCounts, filters), + call(getFrequency, filters), + call(getTimeToClose, filters), + ]); + + return { + lastUpdated, + pins, + counts, + frequency, + timeToClose, + }; } /* ////////////////// FILTERS //////////////// */ From 1e6098d6d4fb5c5f4916cf37a494fb4ac95a65ab Mon Sep 17 00:00:00 2001 From: Jake Mensch Date: Thu, 12 Mar 2020 21:55:35 -0700 Subject: [PATCH 09/10] updated timetoclose endpoint --- server/src/app.py | 19 +++--- server/src/services/time_to_close.py | 99 +++++++++++++++++++++++++++- 2 files changed, 109 insertions(+), 9 deletions(-) diff --git a/server/src/app.py b/server/src/app.py index 43610055e..729563bf8 100644 --- a/server/src/app.py +++ b/server/src/app.py @@ -50,19 +50,22 @@ async def index(request): return json('You hit the index') -@app.route('/timetoclose') +@app.route('/timetoclose', methods=["POST"]) @compress.compress() async def timetoclose(request): ttc_worker = time_to_close(app.config['Settings']) - # dates = loads(ttc_worker.ttc_view_dates()) - summary = ttc_worker.ttc_summary(allData=True, - service=True, - allRequests=False, - requestType="'Bulky Items'", - viewDates=True) + postArgs = request.json + start = postArgs.get('startDate', None) + end = postArgs.get('endDate', None) + ncs = postArgs.get('ncList', []) + requests = postArgs.get('requestTypes', []) - return json(summary) + data = ttc_worker.ttc(startDate=start, + endDate=end, + ncList=ncs, + requestTypes=requests) + return json(data) @app.route('/requestfrequency') diff --git a/server/src/services/time_to_close.py b/server/src/services/time_to_close.py index de369d737..567039fdf 100644 --- a/server/src/services/time_to_close.py +++ b/server/src/services/time_to_close.py @@ -2,6 +2,8 @@ import sqlalchemy as db import pandas as pd import json +from .dataService import DataService +import numpy as np class time_to_close(object): @@ -18,8 +20,102 @@ def __init__(self, else self.config['Database']['DB_CONNECTION_STRING'] self.table = tableName self.data = None + self.dataAccess = DataService(config, tableName) pass + def ttc(self, startDate=None, endDate=None, ncList=[], requestTypes=[]): + """ + For each requestType, returns the statistics necessary to generate + a boxplot of the number of days it took to close the requests. + + Example response: + { + lastPulled: Timestamp, + data: { + 'Bulky Items': { + 'min': float, + 'q1': float, + 'median': float, + 'q3': float, + 'max': float, + 'whiskerMin': float, + 'whiskerMax': float, + 'outliers': [float], + 'count': int + } + ... + } + } + """ + + def get_boxplot_stats(arr, C=1.5): + """ + Takes a one-dimensional numpy array of floats and generates boxplot + statistics for the data. The basic algorithm is standard. + See https://en.wikipedia.org/wiki/Box_plot + + The max length of the whiskers is the constant C, multiplied by the + interquartile range. This is a common method, although there + are others. The default value of C=1.5 is typical when this + method is used. + See matplotlib.org/3.1.3/api/_as_gen/matplotlib.pyplot.boxplot.html + """ + + # calculate first and third quantiles + q1 = np.quantile(arr, 0.25) + q3 = np.quantile(arr, 0.75) + + # calculate whiskers + iqr = q3 - q1 + whiskerMin = arr[arr >= q1 - C * iqr].min() + whiskerMax = arr[arr <= q3 + C * iqr].max() + + # calculate outliers + minOutliers = arr[arr < whiskerMin] + maxOutliers = arr[arr > whiskerMax] + outliers = list(np.concatenate((minOutliers, maxOutliers))) + + return { + 'min': np.min(arr), + 'q1': q1, + 'median': np.median(arr), + 'q3': q3, + 'max': np.max(arr), + 'whiskerMin': whiskerMin, + 'whiskerMax': whiskerMax, + 'outliers': outliers, + 'count': len(arr) + } + + # grab the necessary data from the db + fields = ['requesttype', 'createddate', 'closeddate'] + filters = self.dataAccess.standardFilters( + startDate, endDate, ncList, requestTypes) + data = self.dataAccess.query(fields, filters) + + # read into a dataframe, drop the nulls, and halt if no rows exist + df = pd.DataFrame(data['data']).dropna() + if len(df) == 0: + data['data'] = {} + return data + + # generate a new dataframe that contains the number of days it + # takes to close each request, plus the type of request + df['closeddate'] = pd.to_datetime(df['closeddate']) + df['createddate'] = pd.to_datetime(df['createddate']) + df['time-to-close'] = df['closeddate'] - df['createddate'] + df['hours-to-close'] = df['time-to-close'].astype('timedelta64[h]') + df['days-to-close'] = (df['hours-to-close'] / 24).round(2) + dtc_df = df[['requesttype', 'days-to-close']] + + # group the requests by type and get box plot stats for each type + data['data'] = dtc_df \ + .groupby(by='requesttype') \ + .apply(lambda df: get_boxplot_stats(df['days-to-close'].values)) \ + .to_dict() + + return data + def ttc_view_dates(self, service=False): """ Returns all rows under the CreatedDate and @@ -131,7 +227,8 @@ def ttc_summary(self, """ Returns summary data of the amount of time it takes for a request to close as a dataframe. - If service is set to True, returns summary data of time_to_service as well + If service is set to True, returns summary data of time_to_service + as well If allData is set to True, returns the data of every entry as well If allRequests are set to False, queries data of the value of requestType only From 2f8816bbe1f9aedcca23a9a2d936a7e9b23f51ab Mon Sep 17 00:00:00 2001 From: Jake Mensch Date: Thu, 12 Mar 2020 22:04:34 -0700 Subject: [PATCH 10/10] added test config for pytest --- server/test/test_time_to_close.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/server/test/test_time_to_close.py b/server/test/test_time_to_close.py index 3cdf806b1..85707b928 100644 --- a/server/test/test_time_to_close.py +++ b/server/test/test_time_to_close.py @@ -1,5 +1,11 @@ from src.services.time_to_close import time_to_close +TESTCONFIG = { + "Database": { + "DB_CONNECTION_STRING": "postgresql://testingString/postgresql" + } +} + def test_serviceExists(): # Arrange @@ -7,7 +13,7 @@ def test_serviceExists(): print(testString) # Act - ttc_worker = time_to_close() + ttc_worker = time_to_close(TESTCONFIG) print(ttc_worker) # Assert