diff --git a/agency-dashboard/src/DashboardView.tsx b/agency-dashboard/src/DashboardView.tsx index 76e5728ce..72bfa8bda 100644 --- a/agency-dashboard/src/DashboardView.tsx +++ b/agency-dashboard/src/DashboardView.tsx @@ -21,7 +21,9 @@ import { ReactComponent as LeftArrowIcon } from "@justice-counts/common/assets/l import { ReactComponent as ShareIcon } from "@justice-counts/common/assets/share-icon.svg"; import { DatapointsView } from "@justice-counts/common/components/DataViz/DatapointsView"; import { MetricInsights } from "@justice-counts/common/components/DataViz/MetricInsights"; +import { transformDataForMetricInsights } from "@justice-counts/common/components/DataViz/utils"; import { COMMON_DESKTOP_WIDTH } from "@justice-counts/common/components/GlobalStyles"; +import { DataVizTimeRangesMap } from "@justice-counts/common/types"; import { observer } from "mobx-react-lite"; import React, { useEffect, useState } from "react"; import { useLocation, useNavigate, useParams } from "react-router-dom"; @@ -91,7 +93,16 @@ const DashboardView = () => { const navigate = useNavigate(); const params = useParams(); const agencyId = Number(params.id); - const { datapointsStore } = useStore(); + const { datapointsStore, dataVizStore } = useStore(); + + const { + timeRange, + disaggregationName, + countOrPercentageView, + setTimeRange, + setDisaggregationName, + setCountOrPercentageView, + } = dataVizStore; const { search } = useLocation(); const query = new URLSearchParams(search); @@ -151,8 +162,10 @@ const DashboardView = () => { const metricName = datapointsStore.metricKeyToDisplayName[metricKey] || metricKey; - const datapoints = - datapointsStore.datapointsByMetric[metricKey]?.aggregate || []; + const filteredAggregateData = transformDataForMetricInsights( + datapointsStore.datapointsByMetric[metricKey]?.aggregate || [], + DataVizTimeRangesMap[dataVizStore.timeRange] + ); return ( @@ -160,7 +173,7 @@ const DashboardView = () => { navigate(`/agency/${agencyId}`)} /> {metricName} - + Measures the number of individuals with at least one parole violation during the reporting period. @@ -181,6 +194,12 @@ const DashboardView = () => { dimensionNamesByDisaggregation={ datapointsStore.dimensionNamesByMetricAndDisaggregation[metricKey] } + timeRange={timeRange} + disaggregationName={disaggregationName} + countOrPercentageView={countOrPercentageView} + setTimeRange={setTimeRange} + setDisaggregationName={setDisaggregationName} + setCountOrPercentageView={setCountOrPercentageView} metricNames={metricNames} onMetricsSelect={(metric) => navigate(`/agency/${agencyId}/dashboard?metric=${metric}`) diff --git a/agency-dashboard/src/stores/RootStore.ts b/agency-dashboard/src/stores/RootStore.ts index d11002b7b..27378031a 100644 --- a/agency-dashboard/src/stores/RootStore.ts +++ b/agency-dashboard/src/stores/RootStore.ts @@ -15,13 +15,18 @@ // along with this program. If not, see . // ============================================================================= +import DataVizStore from "@justice-counts/common/stores/DataVizStore"; + import DatapointsStore from "./DatapointsStore"; class RootStore { datapointsStore: DatapointsStore; + dataVizStore: DataVizStore; + constructor() { this.datapointsStore = new DatapointsStore(); + this.dataVizStore = new DataVizStore(); } } diff --git a/common/components/DataViz/DatapointsTitle.tsx b/common/components/DataViz/DatapointsTitle.tsx index b47a1013b..8448e18a9 100644 --- a/common/components/DataViz/DatapointsTitle.tsx +++ b/common/components/DataViz/DatapointsTitle.tsx @@ -33,8 +33,7 @@ const reportFrequencyBadgeColors: BadgeColorMapping = { export const DatapointsTitle: React.FC<{ metricName: string; metricFrequency?: string; - insights?: string; -}> = ({ metricName, metricFrequency, insights }) => { +}> = ({ metricName, metricFrequency }) => { const [titleWidth, setTitleWidth] = useState(0); const titleRef = useRef(null); @@ -44,7 +43,7 @@ export const DatapointsTitle: React.FC<{ return ( - + {metricName} {metricFrequency && ( void; + setDisaggregationName: (disaggregation: string) => void; + setCountOrPercentageView: (viewSetting: DataVizCountOrPercentageView) => void; metricName?: string; metricFrequency?: ReportFrequency; metricNames?: string[]; @@ -103,6 +106,12 @@ export const DatapointsView: React.FC<{ }> = ({ datapointsGroupedByAggregateAndDisaggregations, dimensionNamesByDisaggregation, + timeRange, + disaggregationName, + countOrPercentageView, + setTimeRange, + setDisaggregationName, + setCountOrPercentageView, metricName, metricFrequency, metricNames, @@ -110,56 +119,51 @@ export const DatapointsView: React.FC<{ showBottomMetricInsights = false, resizeHeight = false, }) => { - const [selectedTimeRange, setSelectedTimeRange] = - React.useState("All"); - const [selectedDisaggregation, setSelectedDisaggregation] = - React.useState(noDisaggregationOption); - const [datapointsViewSetting, setDatapointsViewSetting] = - React.useState("Count"); const [mobileSelectMetricsVisible, setMobileSelectMetricsVisible] = React.useState(false); - const data = - (selectedDisaggregation !== noDisaggregationOption && + const selectedData = + (disaggregationName !== NoDisaggregationOption && Object.values( - datapointsGroupedByAggregateAndDisaggregations.disaggregations[ - selectedDisaggregation + datapointsGroupedByAggregateAndDisaggregations?.disaggregations[ + disaggregationName ] || {} )) || datapointsGroupedByAggregateAndDisaggregations?.aggregate || []; - const isAnnual = data[0]?.frequency === "ANNUAL"; - const disaggregations = Object.keys(dimensionNamesByDisaggregation); + const isAnnual = selectedData[0]?.frequency === "ANNUAL"; + const disaggregations = Object.keys(dimensionNamesByDisaggregation || {}); const disaggregationOptions = [...disaggregations]; disaggregationOptions.unshift(noDisaggregationOption); const dimensionNames = - selectedDisaggregation !== noDisaggregationOption - ? (dimensionNamesByDisaggregation[selectedDisaggregation] || []) + disaggregationName !== noDisaggregationOption + ? (dimensionNamesByDisaggregation?.[disaggregationName] || []) .slice() // Must use slice() before sorting a MobX observableArray .sort(sortDatapointDimensions) : [DataVizAggregateName]; - const selectedTimeRangeValue = DataVizTimeRangesMap[selectedTimeRange]; + const selectedTimeRangeValue = DataVizTimeRangesMap[timeRange]; useEffect(() => { if (isAnnual && selectedTimeRangeValue === 6) { - setSelectedTimeRange("All"); + setTimeRange("All"); } - if (!disaggregationOptions.includes(selectedDisaggregation)) { - setSelectedDisaggregation(noDisaggregationOption); - setDatapointsViewSetting("Count"); + if (!disaggregationOptions.includes(disaggregationName)) { + setDisaggregationName(noDisaggregationOption); + setCountOrPercentageView("Count"); } - if (selectedDisaggregation === noDisaggregationOption) { - setDatapointsViewSetting("Count"); + if (disaggregationName === noDisaggregationOption) { + setCountOrPercentageView("Count"); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [datapointsGroupedByAggregateAndDisaggregations]); useEffect(() => { - if (selectedDisaggregation === noDisaggregationOption) { - setDatapointsViewSetting("Count"); + if (disaggregationName === noDisaggregationOption) { + setCountOrPercentageView("Count"); } - }, [selectedDisaggregation]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [disaggregationName]); /** Prevent body from scrolling when modal is open */ useEffect(() => { @@ -174,14 +178,14 @@ export const DatapointsView: React.FC<{ const renderChartForMetric = () => { return ( @@ -189,7 +193,7 @@ export const DatapointsView: React.FC<{ }; const renderLegend = () => { - if (selectedDisaggregation !== noDisaggregationOption) { + if (disaggregationName !== noDisaggregationOption) { return ; } return ; @@ -200,7 +204,7 @@ export const DatapointsView: React.FC<{ { - setSelectedTimeRange(key); + setTimeRange(key as DataVizTimeRangeDisplayName); }} /> {disaggregationOptions.length > 1 && ( { - setSelectedDisaggregation(key); + setDisaggregationName(key); }} /> )} - {selectedDisaggregation !== noDisaggregationOption && ( + {disaggregationName !== noDisaggregationOption && ( { - setDatapointsViewSetting(key as DatapointsViewSetting); + setCountOrPercentageView(key as DataVizCountOrPercentageView); }} /> )} @@ -236,22 +240,11 @@ export const DatapointsView: React.FC<{ ); }; - // insights data - const dataSelectedInTimeRange = filterNullDatapoints( - filterByTimeRange( - datapointsGroupedByAggregateAndDisaggregations?.aggregate || [], - selectedTimeRangeValue - ) - ); - const percentChange = getPercentChangeOverTime(dataSelectedInTimeRange); - const avgValue = getAverageTotalValue(dataSelectedInTimeRange, isAnnual); - const mostRecentValue = getLatestDateFormatted( - dataSelectedInTimeRange, - isAnnual + const filteredAggregateData = transformDataForMetricInsights( + datapointsGroupedByAggregateAndDisaggregations?.aggregate || [], + selectedTimeRangeValue ); - const chartViewInsightsInfo = `Year-to-Year: ${percentChange},\nAvg. Total Value: ${avgValue},\nMost Recent: ${mostRecentValue}`; - return ( @@ -260,10 +253,9 @@ export const DatapointsView: React.FC<{ - {data.length > 0 && ( - + {selectedData.length > 0 && ( + )} )} @@ -284,7 +276,7 @@ export const DatapointsView: React.FC<{ {renderLegend()} {showBottomMetricInsights && ( - + )} diff --git a/common/components/DataViz/utils.test.ts b/common/components/DataViz/utils.test.ts index cabc85cdb..2c86d7b57 100644 --- a/common/components/DataViz/utils.test.ts +++ b/common/components/DataViz/utils.test.ts @@ -22,7 +22,7 @@ import { filterNullDatapoints, incrementMonth, incrementYear, - transformData, + transformDataForBarChart, transformToRelativePerchanges, } from "./utils"; @@ -2059,8 +2059,8 @@ describe("fillTimeGapsBetweenDatapoints", () => { describe("transformData", () => { test("putting it all together", () => { - expect(transformData(testDatapoints5, 60, "Percentage")).toStrictEqual( - testDatapoints5Transformed - ); + expect( + transformDataForBarChart(testDatapoints5, 60, "Percentage") + ).toStrictEqual(testDatapoints5Transformed); }); }); diff --git a/common/components/DataViz/utils.ts b/common/components/DataViz/utils.ts index 0211a7978..e37500238 100644 --- a/common/components/DataViz/utils.ts +++ b/common/components/DataViz/utils.ts @@ -19,8 +19,8 @@ import { mapValues, pickBy } from "lodash"; import { Datapoint, - DatapointsViewSetting, DataVizAggregateName, + DataVizCountOrPercentageView, DataVizTimeRange, } from "../../types"; import { formatNumberInput } from "../../utils"; @@ -255,31 +255,38 @@ export const fillTimeGapsBetweenDatapoints = ( return dataWithGapDatapoints; }; -export const transformData = ( - d: Datapoint[], +// transforms data into the right display format for the data viz chart +export const transformDataForBarChart = ( + datapoints: Datapoint[], monthsAgo: DataVizTimeRange, - datapointsViewSetting: DatapointsViewSetting + dataVizViewSetting: DataVizCountOrPercentageView ) => { - let transformedData = [...d]; - - if (transformedData.length === 0) { - return transformedData; + if (datapoints.length === 0) { + return datapoints; } // filter by time range - transformedData = filterByTimeRange(transformedData, monthsAgo); - - transformedData = filterNullDatapoints(transformedData); + let transformedData = transformDataForMetricInsights(datapoints, monthsAgo); // format data into percentages for percentage view - if (datapointsViewSetting === "Percentage") { + if (dataVizViewSetting === "Percentage") { transformedData = transformToRelativePerchanges(transformedData); } return fillTimeGapsBetweenDatapoints(transformedData, monthsAgo); }; -// get insights from data +export const transformDataForMetricInsights = ( + datapoints: Datapoint[], + monthsAgo: DataVizTimeRange +) => { + if (datapoints.length === 0) { + return datapoints; + } + return filterNullDatapoints(filterByTimeRange(datapoints, monthsAgo)); +}; + +// get insights from transformed data export const getPercentChangeOverTime = (data: Datapoint[]) => { if (data.length > 0) { diff --git a/common/stores/DataVizStore.ts b/common/stores/DataVizStore.ts new file mode 100644 index 000000000..aeade37f7 --- /dev/null +++ b/common/stores/DataVizStore.ts @@ -0,0 +1,52 @@ +// Recidiviz - a data platform for criminal justice reform +// Copyright (C) 2022 Recidiviz, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// ============================================================================= + +import { + DataVizCountOrPercentageView, + DataVizTimeRangeDisplayName, + NoDisaggregationOption, +} from "@justice-counts/common/types"; +import { makeAutoObservable } from "mobx"; + +class DataVizStore { + timeRange: DataVizTimeRangeDisplayName; + + disaggregationName: string; + + countOrPercentageView: DataVizCountOrPercentageView; + + constructor() { + makeAutoObservable(this); + this.timeRange = "All"; + this.disaggregationName = NoDisaggregationOption; + this.countOrPercentageView = "Count"; + } + + setTimeRange = (timeRange: DataVizTimeRangeDisplayName) => { + this.timeRange = timeRange; + }; + + setDisaggregationName = (disaggregation: string) => { + this.disaggregationName = disaggregation; + }; + + setCountOrPercentageView = (viewSetting: DataVizCountOrPercentageView) => { + this.countOrPercentageView = viewSetting; + }; +} + +export default DataVizStore; diff --git a/common/types.ts b/common/types.ts index a7182fd31..c93b9b53b 100644 --- a/common/types.ts +++ b/common/types.ts @@ -278,7 +278,16 @@ export interface RawDatapointsByMetric { export type DataVizTimeRange = 0 | 6 | 12 | 60 | 120; -export const DataVizTimeRangesMap: { [key: string]: DataVizTimeRange } = { +export type DataVizTimeRangeDisplayName = + | "All" + | "6 Months Ago" + | "1 Year Ago" + | "5 Years Ago" + | "10 Years Ago"; + +export const DataVizTimeRangesMap: { + [key in DataVizTimeRangeDisplayName]: DataVizTimeRange; +} = { All: 0, "6 Months Ago": 6, "1 Year Ago": 12, @@ -286,7 +295,7 @@ export const DataVizTimeRangesMap: { [key: string]: DataVizTimeRange } = { "10 Years Ago": 120, }; -export type DatapointsViewSetting = "Count" | "Percentage"; +export type DataVizCountOrPercentageView = "Count" | "Percentage"; export interface DimensionNamesByDisaggregation { [disaggregation: string]: string[]; @@ -297,3 +306,5 @@ export interface DimensionNamesByMetricAndDisaggregation { } export const DataVizAggregateName = "Total"; + +export const NoDisaggregationOption = "None"; diff --git a/publisher/src/components/DataViz/ConnectedDatapointsView.tsx b/publisher/src/components/DataViz/ConnectedDatapointsView.tsx index 18bad5d94..3e6a82fa9 100644 --- a/publisher/src/components/DataViz/ConnectedDatapointsView.tsx +++ b/publisher/src/components/DataViz/ConnectedDatapointsView.tsx @@ -17,11 +17,7 @@ import { DatapointsTableView } from "@justice-counts/common/components/DataViz/DatapointsTableView"; import { DatapointsView } from "@justice-counts/common/components/DataViz/DatapointsView"; -import { - DatapointsGroupedByAggregateAndDisaggregations, - RawDatapoint, - ReportFrequency, -} from "@justice-counts/common/types"; +import { ReportFrequency } from "@justice-counts/common/types"; import { observer } from "mobx-react-lite"; import React from "react"; @@ -34,12 +30,19 @@ const ConnectedDatapointsView: React.FC<{ metricFrequency?: ReportFrequency; dataView: ChartView; }> = ({ metric, metricName, metricFrequency, dataView }) => { - const { datapointsStore } = useStore(); - const datapointsForMetric = - datapointsStore.datapointsByMetric[metric] || - ({} as DatapointsGroupedByAggregateAndDisaggregations); - const rawDatapointsForMetric = - datapointsStore.rawDatapointsByMetric[metric] || ([] as RawDatapoint[]); + const { datapointsStore, dataVizStore } = useStore(); + + const datapointsForMetric = datapointsStore.datapointsByMetric[metric]; + const rawDatapointsForMetric = datapointsStore.rawDatapointsByMetric[metric]; + + const { + timeRange, + disaggregationName, + countOrPercentageView, + setTimeRange, + setDisaggregationName, + setCountOrPercentageView, + } = dataVizStore; return ( <> @@ -47,9 +50,14 @@ const ConnectedDatapointsView: React.FC<{ { let transformedData = [...d]; @@ -272,7 +272,7 @@ export const transformData = ( transformedData = filterNullDatapoints(transformedData); // format data into percentages for percentage view - if (datapointsViewSetting === "Percentage") { + if (dataVizViewSetting === "Percentage") { transformedData = transformToRelativePerchanges(transformedData); } diff --git a/publisher/src/stores/RootStore.ts b/publisher/src/stores/RootStore.ts index f8c248a4f..53f0f78c9 100644 --- a/publisher/src/stores/RootStore.ts +++ b/publisher/src/stores/RootStore.ts @@ -15,6 +15,7 @@ // along with this program. If not, see . // ============================================================================= import { Auth0ClientOptions } from "@auth0/auth0-spa-js"; +import DataVizStore from "@justice-counts/common/stores/DataVizStore"; import { AuthStore } from "../components/Auth"; import API from "./API"; @@ -50,6 +51,8 @@ class RootStore { datapointsStore: DatapointsStore; + dataVizStore: DataVizStore; + metricConfigStore: MetricConfigStore; constructor() { @@ -61,6 +64,7 @@ class RootStore { this.reportStore = new ReportStore(this.userStore, this.api); this.formStore = new FormStore(this.reportStore); this.datapointsStore = new DatapointsStore(this.userStore, this.api); + this.dataVizStore = new DataVizStore(); this.metricConfigStore = new MetricConfigStore(this.userStore, this.api); } }