From ad6a674ecbdcb64f5a2c9cd9302c9875f4d3293e Mon Sep 17 00:00:00 2001 From: Ian MacFarland Date: Mon, 1 Feb 2021 12:09:36 -0800 Subject: [PATCH] Refactor demographics utilities (#329) --- .../DemographicFilterSelect.test.tsx | 4 +- .../DemographicFilterSelect.tsx | 6 +- spotlight-client/src/charts/index.ts | 1 + .../DemographicsByCategoryMetric.ts | 6 +- ...istoricalPopulationBreakdownMetric.test.ts | 17 ++- .../HistoricalPopulationBreakdownMetric.ts | 117 ++++++++---------- .../src/contentModels/Metric.test.ts | 4 +- spotlight-client/src/contentModels/Metric.ts | 2 +- .../src/contentModels/RecidivismRateMetric.ts | 3 +- .../SentenceTypeByLocationMetric.ts | 2 +- ...upervisionSuccessRateDemographicsMetric.ts | 2 +- .../src/contentModels/createMetricMapping.ts | 2 +- spotlight-client/src/demographics.ts | 81 ------------ spotlight-client/src/demographics/index.ts | 19 +++ spotlight-client/src/demographics/types.ts | 55 ++++++++ .../src/demographics/utils.test.ts | 59 +++++++++ spotlight-client/src/demographics/utils.ts | 109 ++++++++++++++++ .../DemographicsByCategoryRecord.ts | 2 +- .../HistoricalPopulationBreakdownRecord.ts | 2 +- .../PopulationBreakdownByLocationRecord.ts | 3 +- .../ProgramParticipationCurrentRecord.ts | 3 +- .../src/metricsApi/RecidivismRateRecord.ts | 7 +- .../SentenceTypeByLocationRecord.ts | 7 +- ...upervisionSuccessRateDemographicsRecord.ts | 4 +- .../SupervisionSuccessRateMonthlyRecord.ts | 8 +- spotlight-client/src/metricsApi/index.ts | 1 + spotlight-client/src/metricsApi/types.ts | 51 ++++++++ spotlight-client/src/metricsApi/utils.test.ts | 48 +------ spotlight-client/src/metricsApi/utils.ts | 108 ++-------------- 29 files changed, 389 insertions(+), 344 deletions(-) delete mode 100644 spotlight-client/src/demographics.ts create mode 100644 spotlight-client/src/demographics/index.ts create mode 100644 spotlight-client/src/demographics/types.ts create mode 100644 spotlight-client/src/demographics/utils.test.ts create mode 100644 spotlight-client/src/demographics/utils.ts create mode 100644 spotlight-client/src/metricsApi/types.ts diff --git a/spotlight-client/src/DemographicFilterSelect/DemographicFilterSelect.test.tsx b/spotlight-client/src/DemographicFilterSelect/DemographicFilterSelect.test.tsx index 90d38c54..869a7514 100644 --- a/spotlight-client/src/DemographicFilterSelect/DemographicFilterSelect.test.tsx +++ b/spotlight-client/src/DemographicFilterSelect/DemographicFilterSelect.test.tsx @@ -69,7 +69,7 @@ test("changes demographic filter", () => { fireEvent.click(raceOption); reactImmediately(() => { - expect(metric.demographicView).toBe("race"); + expect(metric.demographicView).toBe("raceOrEthnicity"); expect(menuButton).toHaveTextContent("Race"); }); @@ -86,7 +86,7 @@ test("changes demographic filter", () => { const ageOption = screen.getByRole("option", { name: "Age" }); fireEvent.click(ageOption); reactImmediately(() => { - expect(metric.demographicView).toBe("age"); + expect(metric.demographicView).toBe("ageBucket"); expect(menuButton).toHaveTextContent("Age"); }); diff --git a/spotlight-client/src/DemographicFilterSelect/DemographicFilterSelect.tsx b/spotlight-client/src/DemographicFilterSelect/DemographicFilterSelect.tsx index 2578cbd9..0e7f407e 100644 --- a/spotlight-client/src/DemographicFilterSelect/DemographicFilterSelect.tsx +++ b/spotlight-client/src/DemographicFilterSelect/DemographicFilterSelect.tsx @@ -19,7 +19,7 @@ import { action } from "mobx"; import { observer } from "mobx-react-lite"; import React from "react"; import HistoricalPopulationBreakdownMetric from "../contentModels/HistoricalPopulationBreakdownMetric"; -import { DemographicView, isDemographicView } from "../metricsApi"; +import { DemographicView, isDemographicView } from "../demographics"; import { Dropdown } from "../UiLibrary"; type DemographicFilterOption = { id: DemographicView; label: string }; @@ -33,9 +33,9 @@ const DemographicFilterSelect: React.FC = ({ }) => { const options: DemographicFilterOption[] = [ { id: "total", label: "Total" }, - { id: "race", label: "Race" }, + { id: "raceOrEthnicity", label: "Race" }, { id: "gender", label: "Gender" }, - { id: "age", label: "Age" }, + { id: "ageBucket", label: "Age" }, ]; const onChange = action("change demographic filter", (newFilter: string) => { diff --git a/spotlight-client/src/charts/index.ts b/spotlight-client/src/charts/index.ts index 4224f4f7..c1b20837 100644 --- a/spotlight-client/src/charts/index.ts +++ b/spotlight-client/src/charts/index.ts @@ -18,3 +18,4 @@ export { default as ProportionalBar } from "./ProportionalBar"; export { default as WindowedTimeSeries } from "./WindowedTimeSeries"; export * from "./WindowedTimeSeries"; +export * from "./types"; diff --git a/spotlight-client/src/contentModels/DemographicsByCategoryMetric.ts b/spotlight-client/src/contentModels/DemographicsByCategoryMetric.ts index e9843cff..f5d2e1fb 100644 --- a/spotlight-client/src/contentModels/DemographicsByCategoryMetric.ts +++ b/spotlight-client/src/contentModels/DemographicsByCategoryMetric.ts @@ -16,10 +16,8 @@ // ============================================================================= import { DataSeries } from "../charts/types"; -import { - DemographicsByCategoryRecord, - recordIsTotalByDimension, -} from "../metricsApi"; +import { recordIsTotalByDimension } from "../demographics"; +import { DemographicsByCategoryRecord } from "../metricsApi"; import Metric from "./Metric"; export default class DemographicsByCategoryMetric extends Metric< diff --git a/spotlight-client/src/contentModels/HistoricalPopulationBreakdownMetric.test.ts b/spotlight-client/src/contentModels/HistoricalPopulationBreakdownMetric.test.ts index 5cbf8452..c9786f5a 100644 --- a/spotlight-client/src/contentModels/HistoricalPopulationBreakdownMetric.test.ts +++ b/spotlight-client/src/contentModels/HistoricalPopulationBreakdownMetric.test.ts @@ -18,11 +18,7 @@ import { isEqual } from "date-fns"; import { advanceTo, clear } from "jest-date-mock"; import { runInAction } from "mobx"; -import { - DEMOGRAPHIC_UNKNOWN, - DIMENSION_DATA_KEYS, - DIMENSION_MAPPINGS, -} from "../demographics"; +import { DemographicViewList, getDemographicCategories } from "../demographics"; import { fetchMetrics, HistoricalPopulationBreakdownRecord, @@ -102,7 +98,9 @@ const getMetric = async () => { test("fills in missing data", async () => { const metric = await getMetric(); - DIMENSION_MAPPINGS.forEach((categoryLabels, demographicView) => { + DemographicViewList.forEach((demographicView) => { + if (demographicView === "nofilter") return; + runInAction(() => { metric.demographicView = demographicView; }); @@ -110,15 +108,14 @@ test("fills in missing data", async () => { reactImmediately(() => { const data = metric.dataSeries; if (data) { - Array.from(categoryLabels.keys()).forEach((identifier, index) => { - if (identifier === DEMOGRAPHIC_UNKNOWN) return; + const categories = getDemographicCategories(demographicView); + categories.forEach(({ identifier }, index) => { const series = data[index].coordinates; expect(series.length).toBe(240); const expectedRecordShape = { ...imputedRecordBase }; if (demographicView !== "total") { - const categoryKey = DIMENSION_DATA_KEYS[demographicView]; - expectedRecordShape[categoryKey] = identifier; + expectedRecordShape[demographicView] = identifier; } series.forEach((record) => { // separate imputed records from existing records diff --git a/spotlight-client/src/contentModels/HistoricalPopulationBreakdownMetric.ts b/spotlight-client/src/contentModels/HistoricalPopulationBreakdownMetric.ts index d126ffdd..8744d0a3 100644 --- a/spotlight-client/src/contentModels/HistoricalPopulationBreakdownMetric.ts +++ b/spotlight-client/src/contentModels/HistoricalPopulationBreakdownMetric.ts @@ -18,19 +18,18 @@ import { ascending } from "d3-array"; import { eachMonthOfInterval, format, startOfMonth, subMonths } from "date-fns"; import { makeObservable, observable, runInAction } from "mobx"; -import { DataSeries } from "../charts/types"; +import { DataSeries } from "../charts"; import { - DEMOGRAPHIC_UNKNOWN, - DIMENSION_DATA_KEYS, - DIMENSION_MAPPINGS, + DemographicViewList, + recordIsTotalByDimension, + getDemographicCategories, + RaceIdentifier, + GenderIdentifier, + AgeIdentifier, } from "../demographics"; import { - AgeIdentifier, DemographicFields, - GenderIdentifier, HistoricalPopulationBreakdownRecord, - RaceIdentifier, - recordIsTotalByDimension, } from "../metricsApi"; import { colors } from "../UiLibrary"; import Metric, { BaseMetricConstructorOptions } from "./Metric"; @@ -123,45 +122,44 @@ export default class HistoricalPopulationBreakdownMetric extends Metric< const missingRecords: HistoricalPopulationBreakdownRecord[] = []; // isolate each data series and impute any missing records - DIMENSION_MAPPINGS.forEach((categoryLabels, demographicView) => { + DemographicViewList.forEach((demographicView) => { + if (demographicView === "nofilter") return; + const recordsForDemographicView = transformedData.filter( recordIsTotalByDimension(demographicView) ); - Array.from(categoryLabels.keys()) - // don't need to include unknown in this data; - // they are minimal to nonexistent in historical data and make the legend confusing - .filter((identifier) => identifier !== DEMOGRAPHIC_UNKNOWN) - .forEach((identifier) => { - let recordsForCategory; - if (demographicView !== "total") { - const categoryKey = DIMENSION_DATA_KEYS[demographicView]; - recordsForCategory = recordsForDemographicView.filter( - (record) => record[categoryKey] === identifier - ); - } else { - recordsForCategory = recordsForDemographicView; - } - missingRecords.push( - ...getMissingMonthsForSeries({ - records: recordsForCategory, - includeCurrentMonth, - demographicFields: { - raceOrEthnicity: - demographicView === "race" - ? (identifier as RaceIdentifier) - : "ALL", - gender: - demographicView === "gender" - ? (identifier as GenderIdentifier) - : "ALL", - ageBucket: - demographicView === "age" - ? (identifier as AgeIdentifier) - : "ALL", - }, - }) + + const categories = getDemographicCategories(demographicView); + categories.forEach(({ identifier }) => { + let recordsForCategory; + if (demographicView !== "total") { + recordsForCategory = recordsForDemographicView.filter( + (record) => record[demographicView] === identifier ); - }); + } else { + recordsForCategory = recordsForDemographicView; + } + missingRecords.push( + ...getMissingMonthsForSeries({ + records: recordsForCategory, + includeCurrentMonth, + demographicFields: { + raceOrEthnicity: + demographicView === "raceOrEthnicity" + ? (identifier as RaceIdentifier) + : "ALL", + gender: + demographicView === "gender" + ? (identifier as GenderIdentifier) + : "ALL", + ageBucket: + demographicView === "ageBucket" + ? (identifier as AgeIdentifier) + : "ALL", + }, + }) + ); + }); }); transformedData.push(...missingRecords); @@ -185,28 +183,15 @@ export default class HistoricalPopulationBreakdownMetric extends Metric< const { records, demographicView } = this; if (!records || demographicView === "nofilter") return null; - const labelsForDimension = DIMENSION_MAPPINGS.get(demographicView); - // this should never happen, it's really just a type safety measure. - // if it does, something has gone catastrophically wrong - if (!labelsForDimension) - throw new Error("Unsupported demographic view. Unable to provide data."); - - return ( - Array.from(labelsForDimension) - // don't need to include unknown in this data; - // they are minimal to nonexistent in historical data and make the legend confusing - .filter(([identifier]) => identifier !== DEMOGRAPHIC_UNKNOWN) - .map(([identifier, label], index) => ({ - label, - color: colors.dataViz[index], - coordinates: - demographicView === "total" - ? records - : records.filter( - (record) => - record[DIMENSION_DATA_KEYS[demographicView]] === identifier - ), - })) - ); + const categories = getDemographicCategories(demographicView); + + return categories.map(({ identifier, label }, index) => ({ + label, + color: colors.dataViz[index], + coordinates: + demographicView === "total" + ? records + : records.filter((record) => record[demographicView] === identifier), + })); } } diff --git a/spotlight-client/src/contentModels/Metric.test.ts b/spotlight-client/src/contentModels/Metric.test.ts index e08880a7..23b863b2 100644 --- a/spotlight-client/src/contentModels/Metric.test.ts +++ b/spotlight-client/src/contentModels/Metric.test.ts @@ -181,13 +181,13 @@ test("demographic filter", async () => { reactImmediately(() => expect(metric.records).toMatchSnapshot()); runInAction(() => { - metric.demographicView = "race"; + metric.demographicView = "raceOrEthnicity"; }); reactImmediately(() => expect(metric.records).toMatchSnapshot()); runInAction(() => { - metric.demographicView = "age"; + metric.demographicView = "ageBucket"; }); reactImmediately(() => expect(metric.records).toMatchSnapshot()); diff --git a/spotlight-client/src/contentModels/Metric.ts b/spotlight-client/src/contentModels/Metric.ts index 7a3d29e2..e345a37d 100644 --- a/spotlight-client/src/contentModels/Metric.ts +++ b/spotlight-client/src/contentModels/Metric.ts @@ -25,11 +25,11 @@ import { import { DataSeries } from "../charts/types"; import { ERROR_MESSAGES } from "../constants"; import { LocalityLabels, TenantId } from "../contentApi/types"; +import { DemographicView } from "../demographics"; import { fetchMetrics, RawMetricData, DemographicFields, - DemographicView, LocalityFields, } from "../metricsApi"; import { MetricRecord, CollectionMap } from "./types"; diff --git a/spotlight-client/src/contentModels/RecidivismRateMetric.ts b/spotlight-client/src/contentModels/RecidivismRateMetric.ts index c71d2222..058beece 100644 --- a/spotlight-client/src/contentModels/RecidivismRateMetric.ts +++ b/spotlight-client/src/contentModels/RecidivismRateMetric.ts @@ -16,7 +16,8 @@ // ============================================================================= import { DataSeries } from "../charts/types"; -import { RecidivismRateRecord, recordIsTotalByDimension } from "../metricsApi"; +import { recordIsTotalByDimension } from "../demographics"; +import { RecidivismRateRecord } from "../metricsApi"; import Metric from "./Metric"; export default class RecidivismRateMetric extends Metric { diff --git a/spotlight-client/src/contentModels/SentenceTypeByLocationMetric.ts b/spotlight-client/src/contentModels/SentenceTypeByLocationMetric.ts index 0cd9457b..5ed48a7b 100644 --- a/spotlight-client/src/contentModels/SentenceTypeByLocationMetric.ts +++ b/spotlight-client/src/contentModels/SentenceTypeByLocationMetric.ts @@ -16,8 +16,8 @@ // ============================================================================= import { DataSeries } from "../charts/types"; +import { recordIsTotalByDimension } from "../demographics"; import { - recordIsTotalByDimension, recordMatchesLocality, SentenceTypeByLocationRecord, } from "../metricsApi"; diff --git a/spotlight-client/src/contentModels/SupervisionSuccessRateDemographicsMetric.ts b/spotlight-client/src/contentModels/SupervisionSuccessRateDemographicsMetric.ts index 9b8a5f7d..fb6e4ccf 100644 --- a/spotlight-client/src/contentModels/SupervisionSuccessRateDemographicsMetric.ts +++ b/spotlight-client/src/contentModels/SupervisionSuccessRateDemographicsMetric.ts @@ -16,8 +16,8 @@ // ============================================================================= import { DataSeries } from "../charts/types"; +import { recordIsTotalByDimension } from "../demographics"; import { - recordIsTotalByDimension, recordMatchesLocality, SupervisionSuccessRateDemographicsRecord, } from "../metricsApi"; diff --git a/spotlight-client/src/contentModels/createMetricMapping.ts b/spotlight-client/src/contentModels/createMetricMapping.ts index 674825a3..ab8d8716 100644 --- a/spotlight-client/src/contentModels/createMetricMapping.ts +++ b/spotlight-client/src/contentModels/createMetricMapping.ts @@ -49,8 +49,8 @@ import RecidivismRateMetric from "./RecidivismRateMetric"; import SentenceTypeByLocationMetric from "./SentenceTypeByLocationMetric"; import SupervisionSuccessRateDemographicsMetric from "./SupervisionSuccessRateDemographicsMetric"; import SupervisionSuccessRateMonthlyMetric from "./SupervisionSuccessRateMonthlyMetric"; -import { NOFILTER_KEY, TOTAL_KEY } from "../metricsApi/utils"; import { ERROR_MESSAGES } from "../constants"; +import { NOFILTER_KEY, TOTAL_KEY } from "../demographics"; type MetricMappingFactoryOptions = { localityLabelMapping: TenantContent["localities"]; diff --git a/spotlight-client/src/demographics.ts b/spotlight-client/src/demographics.ts deleted file mode 100644 index 02a44841..00000000 --- a/spotlight-client/src/demographics.ts +++ /dev/null @@ -1,81 +0,0 @@ -// Recidiviz - a data platform for criminal justice reform -// Copyright (C) 2021 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 { DemographicFieldKey, DemographicView, TOTAL_KEY } from "./metricsApi"; - -export const DEMOGRAPHIC_UNKNOWN = "EXTERNAL_UNKNOWN"; - -const AGE_KEYS = { - under25: "<25", - "25_29": "25-29", - "30_34": "30-34", - "35_39": "35-39", - over40: "40<", -}; - -const AGES = new Map([ - [AGE_KEYS.under25, "<25"], - [AGE_KEYS["25_29"], "25-29"], - [AGE_KEYS["30_34"], "30-34"], - [AGE_KEYS["35_39"], "35-39"], - [AGE_KEYS.over40, "40<"], - [DEMOGRAPHIC_UNKNOWN, "Unknown"], -]); - -const GENDERS = new Map([ - ["FEMALE", "Female"], - ["MALE", "Male"], - [DEMOGRAPHIC_UNKNOWN, "Unknown"], -]); - -const RACES = { - nativeAmerican: "AMERICAN_INDIAN_ALASKAN_NATIVE", - black: "BLACK", - hispanic: "HISPANIC", - white: "WHITE", - other: "OTHER", -}; - -// TODO(#314): additional categories in RaceIdentifier that weren't in ND? -const RACE_LABELS = new Map([ - [RACES.nativeAmerican, "Native American"], - [RACES.black, "Black"], - [RACES.hispanic, "Hispanic"], - [RACES.white, "White"], - [RACES.other, "Other"], -]); - -export const DIMENSION_MAPPINGS = new Map< - Exclude, - Map ->([ - ["gender", GENDERS], - ["age", AGES], - ["race", RACE_LABELS], - ["total", new Map([[TOTAL_KEY, "Total"]])], -]); - -export const DIMENSION_DATA_KEYS: { - [key in Extract< - DemographicView, - "race" | "gender" | "age" - >]: DemographicFieldKey; -} = { - race: "raceOrEthnicity", - gender: "gender", - age: "ageBucket", -}; diff --git a/spotlight-client/src/demographics/index.ts b/spotlight-client/src/demographics/index.ts new file mode 100644 index 00000000..0f7ce72b --- /dev/null +++ b/spotlight-client/src/demographics/index.ts @@ -0,0 +1,19 @@ +// Recidiviz - a data platform for criminal justice reform +// Copyright (C) 2021 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 . +// ============================================================================= + +export * from "./types"; +export * from "./utils"; diff --git a/spotlight-client/src/demographics/types.ts b/spotlight-client/src/demographics/types.ts new file mode 100644 index 00000000..b1baffc5 --- /dev/null +++ b/spotlight-client/src/demographics/types.ts @@ -0,0 +1,55 @@ +// Recidiviz - a data platform for criminal justice reform +// Copyright (C) 2021 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 . +// ============================================================================= + +export type TotalIdentifier = "ALL"; +export const TOTAL_KEY: TotalIdentifier = "ALL"; + +type NoFilterIdentifier = "nofilter"; +export const NOFILTER_KEY: NoFilterIdentifier = "nofilter"; + +export type RaceIdentifier = + | TotalIdentifier + | "AMERICAN_INDIAN_ALASKAN_NATIVE" + | "ASIAN" + | "BLACK" + | "HISPANIC" + | "NATIVE_HAWAIIAN_PACIFIC_ISLANDER" + | "WHITE" + | "OTHER"; +export type GenderIdentifier = TotalIdentifier | "FEMALE" | "MALE"; +export type AgeIdentifier = + | TotalIdentifier + | "<25" + | "25-29" + | "30-34" + | "35-39" + | "40<"; + +export const DemographicViewList = [ + "total", + "raceOrEthnicity", + "gender", + "ageBucket", + "nofilter", +] as const; +export type DemographicView = typeof DemographicViewList[number]; +export function isDemographicView(x: string): x is DemographicView { + // because of how the array is typed, `includes` only accepts values + // it already knows are in the array, which ... kind of defeats the purpose + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return DemographicViewList.includes(x as any); +} diff --git a/spotlight-client/src/demographics/utils.test.ts b/spotlight-client/src/demographics/utils.test.ts new file mode 100644 index 00000000..6526e5b4 --- /dev/null +++ b/spotlight-client/src/demographics/utils.test.ts @@ -0,0 +1,59 @@ +// Recidiviz - a data platform for criminal justice reform +// Copyright (C) 2021 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 { DemographicFields } from "../metricsApi"; +import { DemographicView } from "./types"; +import { recordIsTotalByDimension } from "./utils"; + +describe("recordIsTotalByDimension", () => { + const testData: Array = [ + { raceOrEthnicity: "ALL", gender: "ALL", ageBucket: "ALL", count: 1 }, + { raceOrEthnicity: "BLACK", gender: "ALL", ageBucket: "ALL", count: 2 }, + { raceOrEthnicity: "WHITE", gender: "ALL", ageBucket: "ALL", count: 3 }, + { raceOrEthnicity: "ALL", gender: "MALE", ageBucket: "ALL", count: 4 }, + { raceOrEthnicity: "ALL", gender: "FEMALE", ageBucket: "ALL", count: 5 }, + { raceOrEthnicity: "ALL", gender: "ALL", ageBucket: "<25", count: 6 }, + { raceOrEthnicity: "ALL", gender: "ALL", ageBucket: "25-29", count: 7 }, + ]; + + const verifyFilter = ( + view: DemographicView, + expected: DemographicFields[] + ) => { + expect(testData.filter(recordIsTotalByDimension(view))).toEqual(expected); + }; + + test("returns all records", () => { + verifyFilter("nofilter", testData); + }); + + test("returns only totals", () => { + verifyFilter("total", testData.slice(0, 1)); + }); + + test("returns race/ethnicity categories", () => { + verifyFilter("raceOrEthnicity", testData.slice(1, 3)); + }); + + test("returns gender categories", () => { + verifyFilter("gender", testData.slice(3, 5)); + }); + + test("returns age categories", () => { + verifyFilter("ageBucket", testData.slice(5, 7)); + }); +}); diff --git a/spotlight-client/src/demographics/utils.ts b/spotlight-client/src/demographics/utils.ts new file mode 100644 index 00000000..b2e0548d --- /dev/null +++ b/spotlight-client/src/demographics/utils.ts @@ -0,0 +1,109 @@ +// Recidiviz - a data platform for criminal justice reform +// Copyright (C) 2021 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 assertNever from "assert-never"; +import { DemographicFields, isDemographicFieldKey } from "../metricsApi"; +import { + AgeIdentifier, + DemographicView, + DemographicViewList, + GenderIdentifier, + NOFILTER_KEY, + RaceIdentifier, + TOTAL_KEY, +} from "./types"; + +/** + * Returns a filter predicate for the specified demographic view + * that will exclude totals and breakdowns for all other views. + * Respects a special bypass value (see `NOFILTER_KEY`) + */ +export function recordIsTotalByDimension( + demographicView: DemographicView +): (record: DemographicFields) => boolean { + if (demographicView === NOFILTER_KEY) return () => true; + + const keysToCheck = [...DemographicViewList].filter(isDemographicFieldKey); + + if (demographicView !== "total") { + // exclude the view so we wind up with all categories in that view + keysToCheck.splice(keysToCheck.indexOf(demographicView), 1); + } + + return (record) => { + let match = true; + if (demographicView !== "total") { + // filter out totals + match = match && record[demographicView] !== TOTAL_KEY; + } + + // filter out subset permutations + match = match && keysToCheck.every((key) => record[key] === TOTAL_KEY); + + return match; + }; +} + +type TotalCategory = { identifier: "ALL"; label: string }; +const totalCategories: TotalCategory[] = [ + { identifier: TOTAL_KEY, label: "Total" }, +]; + +type RaceOrEthnicityCategory = { + label: string; + identifier: RaceIdentifier; +}; +const raceOrEthnicityCategories: RaceOrEthnicityCategory[] = [ + { identifier: "AMERICAN_INDIAN_ALASKAN_NATIVE", label: "Native American" }, + { identifier: "BLACK", label: "Black" }, + { identifier: "HISPANIC", label: "Hispanic" }, + { identifier: "WHITE", label: "White" }, + { identifier: "OTHER", label: "Other" }, + // TODO(#314): additional categories in RaceIdentifier that weren't in ND? +]; + +type GenderCategory = { label: string; identifier: GenderIdentifier }; +const genderCategories: GenderCategory[] = [ + { identifier: "MALE", label: "Male" }, + { identifier: "FEMALE", label: "Female" }, +]; + +type AgeCategory = { label: string; identifier: AgeIdentifier }; +const ageBucketCategories: AgeCategory[] = [ + { identifier: "<25", label: "<25" }, + { identifier: "25-29", label: "25-29" }, + { identifier: "30-34", label: "30-34" }, + { identifier: "35-39", label: "35-39" }, + { identifier: "40<", label: "40<" }, +]; + +export function getDemographicCategories( + view: Exclude +): (TotalCategory | RaceOrEthnicityCategory | GenderCategory | AgeCategory)[] { + switch (view) { + case "total": + return totalCategories; + case "raceOrEthnicity": + return raceOrEthnicityCategories; + case "gender": + return genderCategories; + case "ageBucket": + return ageBucketCategories; + default: + assertNever(view); + } +} diff --git a/spotlight-client/src/metricsApi/DemographicsByCategoryRecord.ts b/spotlight-client/src/metricsApi/DemographicsByCategoryRecord.ts index 3f424864..d5c1677c 100644 --- a/spotlight-client/src/metricsApi/DemographicsByCategoryRecord.ts +++ b/spotlight-client/src/metricsApi/DemographicsByCategoryRecord.ts @@ -16,8 +16,8 @@ // ============================================================================= import { RawMetricData } from "./fetchMetrics"; +import { DemographicFields } from "./types"; import { - DemographicFields, extractDemographicFields, recordIsProbation, recordIsParole, diff --git a/spotlight-client/src/metricsApi/HistoricalPopulationBreakdownRecord.ts b/spotlight-client/src/metricsApi/HistoricalPopulationBreakdownRecord.ts index e71d0e53..c818187c 100644 --- a/spotlight-client/src/metricsApi/HistoricalPopulationBreakdownRecord.ts +++ b/spotlight-client/src/metricsApi/HistoricalPopulationBreakdownRecord.ts @@ -18,8 +18,8 @@ import { parseISO } from "date-fns"; import { ValuesType } from "utility-types"; import { RawMetricData } from "./fetchMetrics"; +import { DemographicFields } from "./types"; import { - DemographicFields, extractDemographicFields, recordIsParole, recordIsProbation, diff --git a/spotlight-client/src/metricsApi/PopulationBreakdownByLocationRecord.ts b/spotlight-client/src/metricsApi/PopulationBreakdownByLocationRecord.ts index 34a7f13c..88b9bb33 100644 --- a/spotlight-client/src/metricsApi/PopulationBreakdownByLocationRecord.ts +++ b/spotlight-client/src/metricsApi/PopulationBreakdownByLocationRecord.ts @@ -16,11 +16,10 @@ // ============================================================================= import { ValuesType } from "utility-types"; +import { DemographicFields, LocalityFields } from "."; import { RawMetricData } from "./fetchMetrics"; import { - DemographicFields, extractDemographicFields, - LocalityFields, recordIsParole, recordIsProbation, } from "./utils"; diff --git a/spotlight-client/src/metricsApi/ProgramParticipationCurrentRecord.ts b/spotlight-client/src/metricsApi/ProgramParticipationCurrentRecord.ts index c2749cd4..5bc5d110 100644 --- a/spotlight-client/src/metricsApi/ProgramParticipationCurrentRecord.ts +++ b/spotlight-client/src/metricsApi/ProgramParticipationCurrentRecord.ts @@ -17,7 +17,8 @@ import { ValuesType } from "utility-types"; import { RawMetricData } from "./fetchMetrics"; -import { LocalityFields, recordIsParole, recordIsProbation } from "./utils"; +import { LocalityFields } from "./types"; +import { recordIsParole, recordIsProbation } from "./utils"; export type ProgramParticipationCurrentRecord = LocalityFields & { count: number; diff --git a/spotlight-client/src/metricsApi/RecidivismRateRecord.ts b/spotlight-client/src/metricsApi/RecidivismRateRecord.ts index decd1824..9f54c58e 100644 --- a/spotlight-client/src/metricsApi/RecidivismRateRecord.ts +++ b/spotlight-client/src/metricsApi/RecidivismRateRecord.ts @@ -16,11 +16,8 @@ // ============================================================================= import { RawMetricData } from "./fetchMetrics"; -import { - DemographicFields, - extractDemographicFields, - RateFields, -} from "./utils"; +import { DemographicFields, RateFields } from "./types"; +import { extractDemographicFields } from "./utils"; export type RecidivismRateRecord = DemographicFields & RateFields & { diff --git a/spotlight-client/src/metricsApi/SentenceTypeByLocationRecord.ts b/spotlight-client/src/metricsApi/SentenceTypeByLocationRecord.ts index ec52c25a..9d60daad 100644 --- a/spotlight-client/src/metricsApi/SentenceTypeByLocationRecord.ts +++ b/spotlight-client/src/metricsApi/SentenceTypeByLocationRecord.ts @@ -15,12 +15,9 @@ // along with this program. If not, see . // ============================================================================= +import { DemographicFields, LocalityFields } from "."; import { RawMetricData } from "./fetchMetrics"; -import { - DemographicFields, - extractDemographicFields, - LocalityFields, -} from "./utils"; +import { extractDemographicFields } from "./utils"; export type SentenceTypeByLocationRecord = DemographicFields & LocalityFields & { diff --git a/spotlight-client/src/metricsApi/SupervisionSuccessRateDemographicsRecord.ts b/spotlight-client/src/metricsApi/SupervisionSuccessRateDemographicsRecord.ts index 807e6701..8b173dfc 100644 --- a/spotlight-client/src/metricsApi/SupervisionSuccessRateDemographicsRecord.ts +++ b/spotlight-client/src/metricsApi/SupervisionSuccessRateDemographicsRecord.ts @@ -17,11 +17,9 @@ import { ValuesType } from "utility-types"; import { RawMetricData } from "./fetchMetrics"; +import { DemographicFields, LocalityFields, RateFields } from "./types"; import { - DemographicFields, extractDemographicFields, - LocalityFields, - RateFields, recordIsParole, recordIsProbation, } from "./utils"; diff --git a/spotlight-client/src/metricsApi/SupervisionSuccessRateMonthlyRecord.ts b/spotlight-client/src/metricsApi/SupervisionSuccessRateMonthlyRecord.ts index 413622d2..e749142c 100644 --- a/spotlight-client/src/metricsApi/SupervisionSuccessRateMonthlyRecord.ts +++ b/spotlight-client/src/metricsApi/SupervisionSuccessRateMonthlyRecord.ts @@ -17,12 +17,8 @@ import { ValuesType } from "utility-types"; import { RawMetricData } from "./fetchMetrics"; -import { - LocalityFields, - RateFields, - recordIsProbation, - recordIsParole, -} from "./utils"; +import { LocalityFields, RateFields } from "./types"; +import { recordIsProbation, recordIsParole } from "./utils"; export type SupervisionSuccessRateMonthlyRecord = LocalityFields & RateFields & { diff --git a/spotlight-client/src/metricsApi/index.ts b/spotlight-client/src/metricsApi/index.ts index d084e432..72d49319 100644 --- a/spotlight-client/src/metricsApi/index.ts +++ b/spotlight-client/src/metricsApi/index.ts @@ -24,4 +24,5 @@ export * from "./SentenceTypeByLocationRecord"; export * from "./SupervisionSuccessRateDemographicsRecord"; export * from "./SupervisionSuccessRateMonthlyRecord"; export * from "./fetchMetrics"; +export * from "./types"; export * from "./utils"; diff --git a/spotlight-client/src/metricsApi/types.ts b/spotlight-client/src/metricsApi/types.ts new file mode 100644 index 00000000..f02469b8 --- /dev/null +++ b/spotlight-client/src/metricsApi/types.ts @@ -0,0 +1,51 @@ +// Recidiviz - a data platform for criminal justice reform +// Copyright (C) 2021 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 { + AgeIdentifier, + DemographicView, + GenderIdentifier, + RaceIdentifier, +} from "../demographics/types"; + +type DemographicFieldKey = Extract< + DemographicView, + "raceOrEthnicity" | "gender" | "ageBucket" +>; +export function isDemographicFieldKey( + x: DemographicView +): x is DemographicFieldKey { + return ["raceOrEthnicity", "gender", "ageBucket"].includes(x); +} + +export type DemographicFields = { + [key in DemographicFieldKey]: key extends "raceOrEthnicity" + ? RaceIdentifier + : key extends "gender" + ? GenderIdentifier + : AgeIdentifier; +}; + +export type LocalityFields = { + locality: string; +}; + +export type RateFields = { + rateDenominator: number; + rateNumerator: number; + rate: number; +}; diff --git a/spotlight-client/src/metricsApi/utils.test.ts b/spotlight-client/src/metricsApi/utils.test.ts index ebecd928..546b3071 100644 --- a/spotlight-client/src/metricsApi/utils.test.ts +++ b/spotlight-client/src/metricsApi/utils.test.ts @@ -15,52 +15,8 @@ // along with this program. If not, see . // ============================================================================= -import { - DemographicFields, - DemographicView, - LocalityFields, - recordIsTotalByDimension, - recordMatchesLocality, -} from "./utils"; - -describe("recordIsTotalByDimension", () => { - const testData: Array = [ - { raceOrEthnicity: "ALL", gender: "ALL", ageBucket: "ALL", count: 1 }, - { raceOrEthnicity: "BLACK", gender: "ALL", ageBucket: "ALL", count: 2 }, - { raceOrEthnicity: "WHITE", gender: "ALL", ageBucket: "ALL", count: 3 }, - { raceOrEthnicity: "ALL", gender: "MALE", ageBucket: "ALL", count: 4 }, - { raceOrEthnicity: "ALL", gender: "FEMALE", ageBucket: "ALL", count: 5 }, - { raceOrEthnicity: "ALL", gender: "ALL", ageBucket: "<25", count: 6 }, - { raceOrEthnicity: "ALL", gender: "ALL", ageBucket: "25-29", count: 7 }, - ]; - - const verifyFilter = ( - view: DemographicView, - expected: DemographicFields[] - ) => { - expect(testData.filter(recordIsTotalByDimension(view))).toEqual(expected); - }; - - test("returns all records", () => { - verifyFilter("nofilter", testData); - }); - - test("returns only totals", () => { - verifyFilter("total", testData.slice(0, 1)); - }); - - test("returns race/ethnicity categories", () => { - verifyFilter("race", testData.slice(1, 3)); - }); - - test("returns gender categories", () => { - verifyFilter("gender", testData.slice(3, 5)); - }); - - test("returns age categories", () => { - verifyFilter("age", testData.slice(5, 7)); - }); -}); +import { LocalityFields } from "./types"; +import { recordMatchesLocality } from "./utils"; describe("recordMatchesLocality", () => { const testData = [ diff --git a/spotlight-client/src/metricsApi/utils.ts b/spotlight-client/src/metricsApi/utils.ts index 4c94e62f..4c674725 100644 --- a/spotlight-client/src/metricsApi/utils.ts +++ b/spotlight-client/src/metricsApi/utils.ts @@ -16,47 +16,14 @@ // ============================================================================= import { ValuesType } from "utility-types"; +import { + AgeIdentifier, + GenderIdentifier, + NOFILTER_KEY, + RaceIdentifier, +} from "../demographics/types"; import { RawMetricData } from "./fetchMetrics"; - -export type TotalIdentifier = "ALL"; - -type NoFilterIdentifier = "nofilter"; - -export type RaceIdentifier = - | TotalIdentifier - | "AMERICAN_INDIAN_ALASKAN_NATIVE" - | "ASIAN" - | "BLACK" - | "HISPANIC" - | "NATIVE_HAWAIIAN_PACIFIC_ISLANDER" - | "WHITE" - | "OTHER"; -export type GenderIdentifier = TotalIdentifier | "FEMALE" | "MALE"; -export type AgeIdentifier = - | TotalIdentifier - | "<25" - | "25-29" - | "30-34" - | "35-39" - | "40<"; - -export type DemographicFieldKey = "raceOrEthnicity" | "gender" | "ageBucket"; - -export type DemographicFields = { [key in DemographicFieldKey]: unknown } & { - raceOrEthnicity: RaceIdentifier; - gender: GenderIdentifier; - ageBucket: AgeIdentifier; -}; - -export type LocalityFields = { - locality: string; -}; - -export type RateFields = { - rateDenominator: number; - rateNumerator: number; - rate: number; -}; +import { DemographicFields, LocalityFields } from "./types"; export function extractDemographicFields( record: ValuesType @@ -77,67 +44,6 @@ export function recordIsProbation(record: ValuesType): boolean { return record.supervision_type === "PROBATION"; } -const DemographicViewList = [ - "total", - "race", - "gender", - "age", - "nofilter", -] as const; -export type DemographicView = typeof DemographicViewList[number]; -export function isDemographicView(x: string): x is DemographicView { - // because of how the array is typed, `includes` only accepts values - // it already knows are in the array, which ... kind of defeats the purpose - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return DemographicViewList.includes(x as any); -} - -const DIMENSION_DATA_KEYS: Record< - Exclude, - keyof DemographicFields -> = { - age: "ageBucket", - gender: "gender", - race: "raceOrEthnicity", -}; - -export const TOTAL_KEY: TotalIdentifier = "ALL"; - -export const NOFILTER_KEY: NoFilterIdentifier = "nofilter"; - -/** - * Returns a filter predicate for the specified demographic view - * that will exclude totals and breakdowns for all other views. - * Respects a special bypass value (see `NOFILTER_KEY`) - */ -export function recordIsTotalByDimension( - demographicView: DemographicView -): (record: DemographicFields) => boolean { - if (demographicView === NOFILTER_KEY) return () => true; - - const keysEnum = { ...DIMENSION_DATA_KEYS }; - - if (demographicView !== "total") { - delete keysEnum[demographicView]; - } - - const otherDataKeys = Object.values(keysEnum); - - return (record) => { - let match = true; - if (demographicView !== "total") { - // filter out totals - match = - match && record[DIMENSION_DATA_KEYS[demographicView]] !== TOTAL_KEY; - } - - // filter out subset permutations - match = match && otherDataKeys.every((key) => record[key] === TOTAL_KEY); - - return match; - }; -} - /** * Returns a filter predicate for the specified locality value * that respects a special bypass value (see `NOFILTER_KEY`)