diff --git a/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/Cells.test.js b/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/Cells.test.js new file mode 100644 index 0000000000..4dc5f527b4 --- /dev/null +++ b/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/Cells.test.js @@ -0,0 +1,270 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Provider } from 'react-redux'; +import configureStore from 'redux-mock-store'; +import { thunk } from 'redux-thunk'; +import cloneDeep from 'lodash/cloneDeep'; + +import { CATEGORY } from '@app/pages/clinicworkspace/TideDashboardV2/tideDashboardSlice'; +import { + PatientCell, + NumericTemplateCell, + AvgGlucoseCell, + TimeInRangePercentBarChartCell, + TimeInTargetPercentCell, + TimeInVeryLowPercentCell, + TimeInAnyLowPercentCell, + TimeInVeryHighPercentCell, + TimeInAnyHighPercentCell, + ChangeTIRCell, + GMICell, + CGMUseCell, + FlagCell, + MoreMenuCell, +} from '@app/pages/clinicworkspace/TideDashboardV2/Cells'; + +const mockStore = configureStore([thunk]); + +const patient = { + id: 'patient-1', + fullName: 'James Jellyfish', + birthDate: '2010-10-10', + mrn: 'mrn-123', + summary: { + cgmStats: { + config: { + lowGlucoseThreshold: 3.9, + highGlucoseThreshold: 10, + }, + periods: { + '14d': { + averageGlucoseMmol: 8.26, + glucoseManagementIndicator: 7.2, + timeInVeryLowPercent: 0.0134, + timeInLowPercent: 0.0434, + timeInAnyLowPercent: 0.0568, + timeInTargetPercent: 0.6712, + timeInTargetPercentDelta: -0.1523, + timeInHighPercent: 0.2011, + timeInAnyHighPercent: 0.2733, + timeInVeryHighPercent: 0.0722, + timeCGMUsePercent: 0.9312, + timeCGMUseMinutes: 18780, + }, + }, + }, + }, +}; + +describe('Cells', () => { + let store; + + const renderComponent = (cell) => { + render({cell}); + }; + + beforeEach(() => { + store = mockStore({ + blip: { + selectedClinicId: 'clinic123', + clinics: { clinic123: { id: 'clinic123', preferredBgUnits: 'mg/dL' } }, + tideDashboardFilters: { summaryPeriod: '14d' }, + }, + }); + }); + + describe('PatientCell', () => { + it('renders the patient name, date of birth and MRN', () => { + renderComponent(); + + expect(screen.getByText('James Jellyfish')).toBeInTheDocument(); + expect(screen.getByText('DOB: 2010-10-10')).toBeInTheDocument(); + expect(screen.getByText('MRN: mrn-123')).toBeInTheDocument(); + }); + }); + + describe('AvgGlucoseCell', () => { + it('renders the average glucose of the active summary period to one decimal place', () => { + renderComponent(); + + expect(screen.getByText('149')).toBeInTheDocument(); // averageGlucoseMmol 8.26 + }); + }); + + describe('TimeInRangePercentBarChartCell', () => { + it('renders a bar summary of the time spent in each range', () => { + renderComponent(); + + // Ranges are labelled using the patient's glucose thresholds, in the clinic's preferred units + expect(screen.getByText('<54')).toBeInTheDocument(); + expect(screen.getByText('54-69')).toBeInTheDocument(); + expect(screen.getByText('70-180')).toBeInTheDocument(); // lowGlucoseThreshold 3.9, highGlucoseThreshold 10 + expect(screen.getByText('181-250')).toBeInTheDocument(); + expect(screen.getByText('>250')).toBeInTheDocument(); + expect(screen.getByText('Units in mg/dL')).toBeInTheDocument(); + + expect(screen.getByText('1')).toBeInTheDocument(); // timeInVeryLowPercent 0.0134 + expect(screen.getByText('4')).toBeInTheDocument(); // timeInLowPercent 0.0434 + expect(screen.getByText('67')).toBeInTheDocument(); // timeInTargetPercent 0.6712 + expect(screen.getByText('21')).toBeInTheDocument(); // timeInHighPercent 0.2011 + expect(screen.getByText('7')).toBeInTheDocument(); // timeInVeryHighPercent 0.0722 + + expect(screen.getByText('93 %')).toBeInTheDocument(); // timeCGMUsePercent 0.9312 + }); + }); + + describe('TimeInTargetPercentCell', () => { + it('renders the time in target as a whole percentage', () => { + renderComponent(); + + expect(screen.getByText('67 %')).toBeInTheDocument(); // timeInTargetPercent 0.6712 + }); + }); + + describe('TimeInVeryLowPercentCell', () => { + it('renders the time in very low as a whole percentage', () => { + renderComponent(); + + expect(screen.getByText('1 %')).toBeInTheDocument(); // timeInVeryLowPercent 0.0134 + }); + }); + + describe('TimeInAnyLowPercentCell', () => { + it('renders the time in any low as a whole percentage', () => { + renderComponent(); + + expect(screen.getByText('6 %')).toBeInTheDocument(); // timeInAnyLowPercent 0.0568 + }); + }); + + describe('TimeInVeryHighPercentCell', () => { + it('renders the time in very high as a whole percentage', () => { + renderComponent(); + + expect(screen.getByText('7 %')).toBeInTheDocument(); // timeInVeryHighPercent 0.0722 + }); + }); + + describe('TimeInAnyHighPercentCell', () => { + it('renders the time in any high as a whole percentage', () => { + renderComponent(); + + expect(screen.getByText('27 %')).toBeInTheDocument(); // timeInAnyHighPercent 0.2733 + }); + }); + + describe('ChangeTIRCell', () => { + it('renders the change in time in range as a bar and as a percentage', () => { + renderComponent(); + + expect(screen.getByText('-15.2 %')).toBeInTheDocument(); // compact layout value + }); + }); + + describe('GMICell', () => { + it('renders the glucose management indicator as a percentage', () => { + renderComponent(); + + expect(screen.getByText('7.2 %')).toBeInTheDocument(); + }); + }); + + describe('CGMUseCell', () => { + it('renders the CGM use as a whole percentage', () => { + renderComponent(); + + expect(screen.getByText('93 %')).toBeInTheDocument(); // timeCGMUsePercent 0.9312 + }); + }); + + describe('FlagCell', () => { + // Patient whose summary stats sit within every flag threshold, so no flag applies + const meetingTargetsPatient = { + summary: { + cgmStats: { + periods: { + '14d': { + timeInVeryLowPercent: 0.004, + timeInAnyLowPercent: 0.03, + timeInTargetPercentDelta: -0.05, + timeInAnyHighPercent: 0.2, + timeInVeryHighPercent: 0.04, + timeCGMUsePercent: 0.85, + }, + }, + }, + }, + }; + + const ui = (props) => ; + + it('flags the highest-priority range whose threshold the summary meets', () => { + let patient; + + // No flag when the summary is within every threshold + patient = cloneDeep(meetingTargetsPatient); + const { container, rerender } = render(ui({ patient })); + expect(container).toBeEmptyDOMElement(); + + // Time in very low at or above 1% flags Very Low + patient = cloneDeep(meetingTargetsPatient); + patient.summary.cgmStats.periods['14d'].timeInVeryLowPercent = 0.0134; + rerender(ui({ patient })); + expect(screen.getByText('Very Low')).toBeInTheDocument(); + + // Time in any low at or above 4% flags Low + patient = cloneDeep(meetingTargetsPatient); + patient.summary.cgmStats.periods['14d'].timeInAnyLowPercent = 0.0568; + rerender(ui({ patient })); + expect(screen.getByText('Low')).toBeInTheDocument(); + expect(screen.queryByText('Very Low')).not.toBeInTheDocument(); + + // Drop in time in target at or below -15% flags Drop in TIR + patient = cloneDeep(meetingTargetsPatient); + patient.summary.cgmStats.periods['14d'].timeInTargetPercentDelta = -0.1523; + rerender(ui({ patient })); + expect(screen.getByText('Drop in TIR')).toBeInTheDocument(); + + // Time in any high at or above 25% flags High + patient = cloneDeep(meetingTargetsPatient); + patient.summary.cgmStats.periods['14d'].timeInAnyHighPercent = 0.2733; + rerender(ui({ patient })); + expect(screen.getByText('High')).toBeInTheDocument(); + + // Time in very high at or above 5% flags Very High + patient = cloneDeep(meetingTargetsPatient); + patient.summary.cgmStats.periods['14d'].timeInVeryHighPercent = 0.0722; + rerender(ui({ patient })); + expect(screen.getByText('Very High')).toBeInTheDocument(); + + // CGM use below 70% flags Low CGM Wear + patient = cloneDeep(meetingTargetsPatient); + patient.summary.cgmStats.periods['14d'].timeCGMUsePercent = 0.65; + rerender(ui({ patient })); + expect(screen.getByText('Low CGM Wear')).toBeInTheDocument(); + + // When several thresholds are met, only the highest-priority flag shows + patient = cloneDeep(meetingTargetsPatient); + patient.summary.cgmStats.periods['14d'].timeInVeryLowPercent = 0.0134; + patient.summary.cgmStats.periods['14d'].timeInAnyLowPercent = 0.0568; + rerender(ui({ patient })); + + expect(screen.getByText('Very Low')).toBeInTheDocument(); + expect(screen.queryByText('Low')).not.toBeInTheDocument(); + }); + + it('flags the current category ahead of a higher-priority flag', () => { + // The summary meets both the Very Low and Low thresholds, which would normally flag Very Low + let patient = cloneDeep(meetingTargetsPatient); + patient.summary.cgmStats.periods['14d'].timeInVeryLowPercent = 0.0134; + patient.summary.cgmStats.periods['14d'].timeInAnyLowPercent = 0.0568; + + renderComponent(); + + // The current dashboard category wins + expect(screen.getByText('Low')).toBeInTheDocument(); + expect(screen.queryByText('Very Low')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/TideDashboardV2.test.js b/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/TideDashboardV2.test.js index 39514ce732..91bacb61af 100644 --- a/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/TideDashboardV2.test.js +++ b/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/TideDashboardV2.test.js @@ -222,10 +222,23 @@ describe('TideDashboardV2', () => { // All Patients is the pre-selected category expect(await screen.findByText('Default Patient 1')).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: /All Patients/ })).toBeChecked(); expect(screen.getByText('Default Patient 2')).toBeInTheDocument(); expect(screen.getByText('DOB: 2001-01-01')).toBeInTheDocument(); + expect(screen.getAllByRole('columnheader')).toHaveLength(10); + expect(screen.getByRole('columnheader', { name: /Patient Details/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Flag/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Avg Glucose/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Time in Range/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /% Change in TIR/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /GMI/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /CGM Use/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Tags/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Last Reviewed/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /More Options/ })).toBeInTheDocument(); + // Selecting Very Low fetches and shows the Very Low cohort await userEvent.click(screen.getByRole('radio', { name: /Very Low/ })); expect(await screen.findByText('Very Low Patient 1')).toBeInTheDocument(); @@ -233,6 +246,18 @@ describe('TideDashboardV2', () => { expect(screen.getByText('Very Low Patient 2')).toBeInTheDocument(); expect(screen.queryByText('Default Patient 1')).not.toBeInTheDocument(); + expect(screen.getAllByRole('columnheader')).toHaveLength(10); + expect(screen.getByRole('columnheader', { name: /Patient Details/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Avg Glucose/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /% Time < 54/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /% Time < 70/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /% TIR 70-180/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Time in Range/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /% Change in TIR/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Tags/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Last Reviewed/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /More Options/ })).toBeInTheDocument(); + // Selecting Low fetches and shows the Low cohort await userEvent.click(screen.getByRole('radio', { name: /^Low$/ })); expect(await screen.findByText('Low Patient 1')).toBeInTheDocument(); @@ -240,6 +265,18 @@ describe('TideDashboardV2', () => { expect(screen.getByText('Low Patient 2')).toBeInTheDocument(); expect(screen.queryByText('Very Low Patient 1')).not.toBeInTheDocument(); + expect(screen.getAllByRole('columnheader')).toHaveLength(10); + expect(screen.getByRole('columnheader', { name: /Patient Details/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Avg Glucose/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /% Time < 54/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /% Time < 70/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /% TIR 70-180/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Time in Range/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /% Change in TIR/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Tags/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Last Reviewed/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /More Options/ })).toBeInTheDocument(); + // Selecting Drop in TIR fetches and shows the Drop in TIR cohort await userEvent.click(screen.getByRole('radio', { name: /Drop in TIR/ })); expect(await screen.findByText('Drop In TIR Patient 1')).toBeInTheDocument(); @@ -247,6 +284,18 @@ describe('TideDashboardV2', () => { expect(screen.getByText('Drop In TIR Patient 2')).toBeInTheDocument(); expect(screen.queryByText('Low Patient 1')).not.toBeInTheDocument(); + expect(screen.getAllByRole('columnheader')).toHaveLength(10); + expect(screen.getByRole('columnheader', { name: /Patient Details/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Avg Glucose/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /% TIR 70-180/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Time in Range/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /% Change in TIR/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /GMI/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /CGM Use/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Tags/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Last Reviewed/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /More Options/ })).toBeInTheDocument(); + // Selecting High fetches and shows the High cohort await userEvent.click(screen.getByRole('radio', { name: /^High$/ })); expect(await screen.findByText('High Patient 1')).toBeInTheDocument(); @@ -254,6 +303,18 @@ describe('TideDashboardV2', () => { expect(screen.getByText('High Patient 2')).toBeInTheDocument(); expect(screen.queryByText('Drop In TIR Patient 1')).not.toBeInTheDocument(); + expect(screen.getAllByRole('columnheader')).toHaveLength(10); + expect(screen.getByRole('columnheader', { name: /Patient Details/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Avg Glucose/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /% Time > 250/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /% Time > 180/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /% TIR 70-180/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Time in Range/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /% Change in TIR/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Tags/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Last Reviewed/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /More Options/ })).toBeInTheDocument(); + // Selecting Very High fetches and shows the Very High cohort await userEvent.click(screen.getByRole('radio', { name: /Very High/ })); expect(await screen.findByText('Very High Patient 1')).toBeInTheDocument(); @@ -261,6 +322,18 @@ describe('TideDashboardV2', () => { expect(screen.getByText('Very High Patient 2')).toBeInTheDocument(); expect(screen.queryByText('High Patient 1')).not.toBeInTheDocument(); + expect(screen.getAllByRole('columnheader')).toHaveLength(10); + expect(screen.getByRole('columnheader', { name: /Patient Details/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Avg Glucose/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /% Time > 250/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /% Time > 180/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /% TIR 70-180/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Time in Range/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /% Change in TIR/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Tags/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Last Reviewed/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /More Options/ })).toBeInTheDocument(); + // Selecting Low CGM Wear fetches and shows the Low CGM Wear cohort await userEvent.click(screen.getByRole('radio', { name: /Low CGM Wear/ })); expect(await screen.findByText('Low CGM Wear Patient 1')).toBeInTheDocument(); @@ -268,12 +341,35 @@ describe('TideDashboardV2', () => { expect(screen.getByText('Low CGM Wear Patient 2')).toBeInTheDocument(); expect(screen.queryByText('Very High Patient 1')).not.toBeInTheDocument(); + expect(screen.getAllByRole('columnheader')).toHaveLength(10); + expect(screen.getByRole('columnheader', { name: /Patient Details/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /CGM Use/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Avg Glucose/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /% TIR 70-180/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Time in Range/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /% Change in TIR/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /GMI/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Tags/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Last Reviewed/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /More Options/ })).toBeInTheDocument(); + // Selecting Meeting Targets fetches and shows the Meeting Targets cohort await userEvent.click(screen.getByRole('radio', { name: /Meeting Targets/ })); expect(await screen.findByText('Meeting Targets Patient 1')).toBeInTheDocument(); expect(screen.getByRole('radio', { name: /Meeting Targets/ })).toBeChecked(); expect(screen.getByText('Meeting Targets Patient 2')).toBeInTheDocument(); expect(screen.queryByText('Low CGM Wear Patient 1')).not.toBeInTheDocument(); + + expect(screen.getAllByRole('columnheader')).toHaveLength(9); + expect(screen.getByRole('columnheader', { name: /Patient Details/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Avg Glucose/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Time in Range/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /% Change in TIR/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /GMI/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /CGM Use/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Tags/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /Last Reviewed/ })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: /More Options/ })).toBeInTheDocument(); }, TEST_TIMEOUT_MS); it('fetches with filters', async () => { diff --git a/app/pages/clinicworkspace/TideDashboardV2/CGMExclusionQuery.js b/app/pages/clinicworkspace/TideDashboardV2/CGMExclusionQuery.js index 36ac4920ac..345eb3edc4 100644 --- a/app/pages/clinicworkspace/TideDashboardV2/CGMExclusionQuery.js +++ b/app/pages/clinicworkspace/TideDashboardV2/CGMExclusionQuery.js @@ -55,4 +55,8 @@ export default class CGMExclusionQuery { getQueryParams(name) { return this.queryParams[name] || {}; } + + getRule(name) { + return this.rules[name] || {}; + } }; diff --git a/app/pages/clinicworkspace/TideDashboardV2/Cells.js b/app/pages/clinicworkspace/TideDashboardV2/Cells.js index 460136296a..9fe657a000 100644 --- a/app/pages/clinicworkspace/TideDashboardV2/Cells.js +++ b/app/pages/clinicworkspace/TideDashboardV2/Cells.js @@ -1,6 +1,20 @@ import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { Box } from 'theme-ui'; +import { useSelector } from 'react-redux'; +import { useTranslation, withTranslation } from 'react-i18next'; +import { Box, Flex, Text } from 'theme-ui'; +import { colors as vizColors } from '@tidepool/viz'; +import { MGDL_UNITS } from '../../../core/constants'; +import { colors } from '../../../themes/baseTheme'; + +import BgSummaryCell from '../../../components/clinic/BgSummaryCell'; +import DeltaBar from '../../../components/elements/DeltaBar'; +import utils from '../../../core/utils'; +import { CATEGORY } from './tideDashboardSlice'; +import isUndefined from 'lodash/isUndefined'; + + +import { tideDashboardExclusionQuery } from './tideDashboardApi'; +import { useFlags } from 'launchdarkly-react-client-sdk'; export const COMPACT = '@container (max-width: 1200px)'; @@ -18,6 +32,224 @@ export const PatientCell = ({ patient }) => { ; }; +export const NumericTemplateCell = ({ value, isPercent = false }) => { + if (!value) return ; + + return {value} {isPercent && '%'}; +}; + +export const AvgGlucoseHeader = withTranslation()(({ t }) => ( + <> + {t('Avg Glucose')} + {t('Avg Gluc.')} + +)); + +export const AvgGlucoseCell = ({ patient }) => { + const summaryPeriod = useSelector(state => state.blip.tideDashboardFilters.summaryPeriod); + const selectedClinicId = useSelector((state) => state.blip.selectedClinicId); + const clinic = useSelector(state => state.blip.clinics?.[selectedClinicId]); + const clinicBgUnits = clinic?.preferredBgUnits || MGDL_UNITS; + + const rawValue = patient?.summary?.cgmStats?.periods?.[summaryPeriod]?.averageGlucoseMmol; + const value = clinicBgUnits === MGDL_UNITS + ? utils.translateBg(rawValue, MGDL_UNITS) + : utils.formatDecimal(rawValue, 1); // MMOLL_UNITS + + return ; +}; + +export const TimeInRangePercentBarChartCell = ({ patient }) => { + const summaryPeriod = useSelector(state => state.blip.tideDashboardFilters.summaryPeriod); + const selectedClinicId = useSelector(state => state.blip.selectedClinicId); + const clinic = useSelector(state => state.blip.clinics?.[selectedClinicId]); + const clinicBgUnits = clinic?.preferredBgUnits || MGDL_UNITS; + + const { showExtremeHigh } = useFlags(); + + return ; +}; + +export const TimeInTargetPercentCell = ({ patient }) => { + const summaryPeriod = useSelector(state => state.blip.tideDashboardFilters.summaryPeriod); + const rawValue = patient?.summary?.cgmStats?.periods?.[summaryPeriod]?.timeInTargetPercent; + let value = utils.formatDecimal(rawValue * 100, 0); + + if (isUndefined(rawValue)) value = ''; + + return ; +}; + +export const TimeInVeryLowPercentCell = ({ patient }) => { + const summaryPeriod = useSelector(state => state.blip.tideDashboardFilters.summaryPeriod); + const rawValue = patient?.summary?.cgmStats?.periods?.[summaryPeriod]?.timeInVeryLowPercent; + const value = utils.formatDecimal(rawValue * 100, 0); + + return ; +}; + +export const TimeInAnyLowPercentCell = ({ patient }) => { + const summaryPeriod = useSelector(state => state.blip.tideDashboardFilters.summaryPeriod); + const rawValue = patient?.summary?.cgmStats?.periods?.[summaryPeriod]?.timeInAnyLowPercent; + const value = utils.formatDecimal(rawValue * 100, 0); + + return ; +}; + +export const TimeInVeryHighPercentCell = ({ patient }) => { + const summaryPeriod = useSelector(state => state.blip.tideDashboardFilters.summaryPeriod); + const rawValue = patient?.summary?.cgmStats?.periods?.[summaryPeriod]?.timeInVeryHighPercent; + const value = utils.formatDecimal(rawValue * 100, 0); + + return ; +}; + +export const TimeInAnyHighPercentCell = ({ patient }) => { + const summaryPeriod = useSelector(state => state.blip.tideDashboardFilters.summaryPeriod); + const rawValue = patient?.summary?.cgmStats?.periods?.[summaryPeriod]?.timeInAnyHighPercent; + const value = utils.formatDecimal(rawValue * 100, 0); + + return ; +}; + +export const GMICell = ({ patient }) => { + const summaryPeriod = useSelector(state => state.blip.tideDashboardFilters.summaryPeriod); + const value = patient?.summary?.cgmStats?.periods?.[summaryPeriod]?.glucoseManagementIndicator; + + return ; +}; + +export const CGMUseCell = ({ patient }) => { + const summaryPeriod = useSelector(state => state.blip.tideDashboardFilters.summaryPeriod); + const rawValue = patient?.summary?.cgmStats?.periods?.[summaryPeriod]?.timeCGMUsePercent; + const value = utils.formatDecimal(rawValue * 100, 0); + + return ; +}; + +export const ChangeTIRHeader = withTranslation()(({ t }) => ( + <> + {t('% Change in TIR')} + {t('% Δ TIR')} + +)); + +export const ChangeTIRCell = ({ patient }) => { + const summaryPeriod = useSelector(state => state.blip.tideDashboardFilters.summaryPeriod); + const timeInTargetPercentDelta = patient?.summary?.cgmStats?.periods?.[summaryPeriod]?.timeInTargetPercentDelta; + + if (!timeInTargetPercentDelta) return -; + + const compactDisplayValue = utils.formatDecimal(timeInTargetPercentDelta * 100, 1); + + return <> + + + + + + + + ; +}; + +export const FlagCell = ({ patient, category = null }) => { + const { t } = useTranslation(); + const summaryPeriod = useSelector(state => state.blip.tideDashboardFilters.summaryPeriod); + const period = patient?.summary?.cgmStats?.periods?.[summaryPeriod]; + + const { VERY_LOW, ANY_LOW, DROP_IN_TIR, ANY_HIGH, VERY_HIGH, LOW_CGM_WEAR } = CATEGORY; + + const getThreshold = (category) => tideDashboardExclusionQuery.getRule(category).threshold; + + if (!period) return null; + + const rangeName = (() => { + switch(true) { + // Current dashboard category takes priority + case category === VERY_LOW: return 'veryLow'; + case category === ANY_LOW: return 'anyLow'; + case category === VERY_HIGH: return 'veryHigh'; + case category === ANY_HIGH: return 'anyHigh'; + case category === DROP_IN_TIR: return 'dropInTIR'; + case category === LOW_CGM_WEAR: return 'lowSensorUsage'; + + // If no category, then read from summary + case period.timeInVeryLowPercent >= getThreshold(VERY_LOW): return 'veryLow'; // >=1% + case period.timeInAnyLowPercent >= getThreshold(ANY_LOW): return 'anyLow'; // >=4% + case period.timeInTargetPercentDelta <= getThreshold(DROP_IN_TIR): return 'dropInTIR'; // <=-15% + case period.timeInAnyHighPercent >= getThreshold(ANY_HIGH): return 'anyHigh'; // >=25% + case period.timeInVeryHighPercent >= getThreshold(VERY_HIGH): return 'veryHigh'; // >=5% + case period.timeCGMUsePercent < getThreshold(LOW_CGM_WEAR): return 'lowSensorUsage'; // <70% + + default: return null; + } + })(); + + if (!rangeName) return null; + + const flagLabels = { + veryLow: t('Very Low'), + anyLow: t('Low'), + veryHigh: t('Very High'), + anyHigh: t('High'), + dropInTIR: t('Drop in TIR'), + lowSensorUsage: t('Low CGM Wear'), + target: t('Meeting Targets'), + }; + + const flagColor = colors.bg[rangeName] || vizColors.gold30; + + return ( + + + + + + {flagLabels[rangeName] || ''} + + + + ); +}; + +export const MoreMenuHeader = () => { + const { t } = useTranslation(); + + return ; +}; + +export const MoreMenuCell = () => <>; // TEMPORARY + export default { PatientCell, + NumericTemplateCell, + AvgGlucoseCell, + TimeInRangePercentBarChartCell, + TimeInVeryLowPercentCell, + ChangeTIRCell, + GMICell, + CGMUseCell, + FlagCell, }; diff --git a/app/pages/clinicworkspace/TideDashboardV2/useTableColumns.js b/app/pages/clinicworkspace/TideDashboardV2/useTableColumns.js index a45c08dda9..34b9a9b9dc 100644 --- a/app/pages/clinicworkspace/TideDashboardV2/useTableColumns.js +++ b/app/pages/clinicworkspace/TideDashboardV2/useTableColumns.js @@ -1,40 +1,234 @@ -import React from 'react'; +import React, { useMemo } from 'react'; +import { useSelector } from 'react-redux'; import { useTranslation } from 'react-i18next'; +import { CATEGORY } from './tideDashboardSlice'; +import { MGDL_UNITS } from '../../../core/constants'; +import mapValues from 'lodash/mapValues'; +import { utils as vizUtils } from '@tidepool/viz'; +const { DEFAULT_BG_BOUNDS } = vizUtils.constants; import { PatientCell, + AvgGlucoseHeader, + AvgGlucoseCell, + CGMUseCell, + ChangeTIRHeader, + ChangeTIRCell, + GMICell, + TimeInRangePercentBarChartCell, + TimeInVeryLowPercentCell, + TimeInAnyLowPercentCell, + TimeInVeryHighPercentCell, + TimeInAnyHighPercentCell, + TimeInTargetPercentCell, + FlagCell, + MoreMenuHeader, } from './Cells'; -const getColumnTypes = (t, category) => ({ +import TagListCell from '../components/TagListCell'; + +const buildColumnTypes = (t, category, thresholds) => ({ patientDetails: { title: t('Patient Details'), field: 'fullName', align: 'left', render: patient => , }, - placeholder: { - title: t('Placeholder'), - field: 'placeholder', + flag: { + title: t('Flag'), + field: 'flag', + align: 'center', + render: patient => , + }, + avgGlucose: { + title: t('Avg Glucose'), + field: 'avgGlucose', + align: 'center', + titleComponent: () => , + render: patient => , + }, + timeInRangeBarChart: { + title: t('Time in Range'), + field: 'timeInRangeBarChart', + align: 'center', + render: patient => , + }, + changeInTIR: { + title: t('% Change in TIR'), + field: 'changeInTIR', + align: 'center', + titleComponent: () => , + render: patient => , + }, + timeInVeryLow: { + title: `${t('% Time')} < ${thresholds.veryLowThreshold}`, + field: 'timeInVeryLow', + align: 'center', + render: patient => , + }, + timeInAnyLow: { + title: `${t('% Time')} < ${thresholds.targetLowerBound}`, + field: 'timeInAnyLow', + align: 'center', + render: patient => , + }, + timeInVeryHigh: { + title: `${t('% Time')} > ${thresholds.veryHighThreshold}`, + field: 'timeInVeryHigh', + align: 'center', + render: patient => , + }, + timeInAnyHigh: { + title: `${t('% Time')} > ${thresholds.targetUpperBound}`, + field: 'timeInAnyHigh', align: 'center', - render: patient => null, + render: patient => , }, + timeInTarget: { + title: `${t('% TIR')} ${thresholds.targetLowerBound}-${thresholds.targetUpperBound}`, + field: 'timeInTarget', + align: 'center', + render: patient => , + }, + gmi: { + title: t('GMI'), + field: 'gmi', + align: 'center', + render: patient => , + }, + cgmUse: { + title: t('CGM Use'), + field: 'cgmUse', + align: 'center', + render: patient => , + }, + tags: { + title: t('Tags'), + field: 'tags', + align: 'center', + render: patient => , + }, + lastReviewed: { + title: t('Last Reviewed'), + field: 'lastReviewed', + align: 'center', + render: patient => null, // TODO: Implement + }, + moreMenu: { + title: '', + field: 'moreMenu', + align: 'center', + titleComponent: () => , + render: patient => null, // TODO: Implement + }, // More }); +const getColumnSet = (columnTypes) => ({ + default: [ + columnTypes.patientDetails, + columnTypes.flag, + columnTypes.avgGlucose, + columnTypes.timeInRangeBarChart, + columnTypes.changeInTIR, + columnTypes.gmi, + columnTypes.cgmUse, + columnTypes.tags, + columnTypes.lastReviewed, + columnTypes.moreMenu, + ], + low: [ + columnTypes.patientDetails, + columnTypes.avgGlucose, + columnTypes.timeInVeryLow, + columnTypes.timeInAnyLow, + columnTypes.timeInTarget, + columnTypes.timeInRangeBarChart, + columnTypes.changeInTIR, + columnTypes.tags, + columnTypes.lastReviewed, + columnTypes.moreMenu, + ], + high: [ + columnTypes.patientDetails, + columnTypes.avgGlucose, + columnTypes.timeInVeryHigh, + columnTypes.timeInAnyHigh, + columnTypes.timeInTarget, + columnTypes.timeInRangeBarChart, + columnTypes.changeInTIR, + columnTypes.tags, + columnTypes.lastReviewed, + columnTypes.moreMenu, + ], + dropInTIR: [ + columnTypes.patientDetails, + columnTypes.avgGlucose, + columnTypes.timeInTarget, + columnTypes.timeInRangeBarChart, + columnTypes.changeInTIR, + columnTypes.gmi, + columnTypes.cgmUse, + columnTypes.tags, + columnTypes.lastReviewed, + columnTypes.moreMenu, + ], + lowCgmWear: [ + columnTypes.patientDetails, + columnTypes.cgmUse, + columnTypes.avgGlucose, + columnTypes.timeInTarget, + columnTypes.timeInRangeBarChart, + columnTypes.changeInTIR, + columnTypes.gmi, + columnTypes.tags, + columnTypes.lastReviewed, + columnTypes.moreMenu, + ], + target: [ + columnTypes.patientDetails, + columnTypes.avgGlucose, + columnTypes.timeInRangeBarChart, + columnTypes.changeInTIR, + columnTypes.gmi, + columnTypes.cgmUse, + columnTypes.tags, + columnTypes.lastReviewed, + columnTypes.moreMenu, + ], +}); + +const getFormattedThresholds = (clinicBgUnits) => { + const thresholds = DEFAULT_BG_BOUNDS[clinicBgUnits]; + const precision = clinicBgUnits === MGDL_UNITS ? 0 : 1; + + return mapValues(thresholds, value => value.toFixed(precision)); +}; + const useTableColumns = (category) => { const { t } = useTranslation(); + const selectedClinicId = useSelector((state) => state.blip.selectedClinicId); + const clinic = useSelector(state => state.blip.clinics?.[selectedClinicId]); + const clinicBgUnits = clinic?.preferredBgUnits || MGDL_UNITS; - const columnTypes = getColumnTypes(t, category); + const columns = useMemo(() => { + const thresholds = getFormattedThresholds(clinicBgUnits); + const columnTypes = buildColumnTypes(t, category, thresholds); + const columnSet = getColumnSet(columnTypes); - return [ - columnTypes.patientDetails, - columnTypes.placeholder, - columnTypes.placeholder, - columnTypes.placeholder, - columnTypes.placeholder, - columnTypes.placeholder, - columnTypes.placeholder, - columnTypes.placeholder, - ]; + switch(category) { + case CATEGORY.DEFAULT: return columnSet.default; + case CATEGORY.VERY_LOW: return columnSet.low; + case CATEGORY.ANY_LOW: return columnSet.low; + case CATEGORY.DROP_IN_TIR: return columnSet.dropInTIR; + case CATEGORY.ANY_HIGH: return columnSet.high; + case CATEGORY.VERY_HIGH: return columnSet.high; + case CATEGORY.LOW_CGM_WEAR: return columnSet.lowCgmWear; + case CATEGORY.TARGET: return columnSet.target; + default: return columnSet.default; + } + }, [category, clinicBgUnits, t]); + + return columns; }; export default useTableColumns; diff --git a/app/pages/clinicworkspace/components/TagListCell.js b/app/pages/clinicworkspace/components/TagListCell.js new file mode 100644 index 0000000000..5a6c8e1646 --- /dev/null +++ b/app/pages/clinicworkspace/components/TagListCell.js @@ -0,0 +1,20 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { TagList } from '../../../components/elements/Tag'; + +const MAX_TAGS = 3; + +const TagListCell = ({ patient }) => { + const selectedClinicId = useSelector(state => state.blip.selectedClinicId); + const clinic = useSelector(state => state.blip.clinics?.[selectedClinicId]); + const patientTags = clinic?.patientTags || []; + + const tagIds = patient?.tags || []; + const tags = tagIds + .map(tag => patientTags.find(ptTag => ptTag.id === tag)) + .filter(Boolean); + + return ; +}; + +export default TagListCell;