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;