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/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']
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
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
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/components/Visualizations/Contact311.jsx b/src/components/Visualizations/Contact311.jsx
index bd82b4fbb..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 (
-
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/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/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 } 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..a32b54104 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,96 +19,40 @@ 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, + pins: [], + counts: {}, + frequency: {}, + timeToClose: {}, }; 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, 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/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..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, @@ -11,27 +12,93 @@ import { getDataFailure, } from './reducers/data'; -const pinUrl = `https://${process.env.DB_URL}/pins`; +/* /////////// INDIVIDUAL API CALLS /////////// */ + +const BASE_URL = process.env.DB_URL; + +function* getPins(filters) { + const pinUrl = `${BASE_URL}/pins`; + + const { data: { lastPulled, data } } = yield call(axios.post, pinUrl, filters); + + return { + lastUpdated: lastPulled, + pins: data, + }; +} + +function* getCounts(filters) { + const countsUrl = `${BASE_URL}/requestcounts`; + + const { data: { data } } = yield call(axios.post, countsUrl, { + ...filters, + countFields: ['requesttype', 'requestsource'], + }); + + return { + type: data.find(d => d.field === 'requesttype')?.counts, + source: data.find(d => d.field === 'requestsource')?.counts, + }; +} + +function* getFrequency() { + return yield {}; +} + +function* getTimeToClose() { + return yield {}; +} + +/* //////////// COMBINED API CALL //////////// */ + +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 //////////////// */ + const getState = (state, slice) => state[slice]; -function* getData() { +function* getFilters() { const { startDate, endDate, councils, requestTypes, - } = yield select(getState, 'data'); + } = 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));