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 ( - ); }; const mapStateToProps = state => ({ - requestTypes: state.data.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/Criteria.jsx b/src/components/Visualizations/Criteria.jsx index acd7e442d..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); @@ -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/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/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, }; 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/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..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.data.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', 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/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));