Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5ae947a
WEB-4654 abstract out TIR range filter
henry-tp Jul 15, 2026
a4d7133
WEB-4654 adjust styling of TIR dropdown
henry-tp Jul 15, 2026
69ce56b
WEB-4654 reposition FilterByTIR to after CGM Use
henry-tp Jul 15, 2026
d3cd1db
Merge branch 'WEB-4654-summary-period' into WEB-4654-time-in-range
henry-tp Jul 15, 2026
4100d98
WEB-4654 change directory for FilterByTIR
henry-tp Jul 15, 2026
c12d0e9
WEB-4654 clear unneeded props
henry-tp Jul 16, 2026
3d8fe0e
Merge branch 'WEB-4654-summary-period' into WEB-4654-time-in-range
henry-tp Jul 17, 2026
b5b6699
WEB-4654 add tests for FilterByTimeInRange
henry-tp Jul 17, 2026
265160e
WEB-4654 add tests for time-in-range
henry-tp Jul 17, 2026
a97d665
Merge branch 'WEB-4654-summary-period' into WEB-4654-time-in-range
henry-tp Jul 17, 2026
a9b544d
WEB-4654 fix metrics tracking
henry-tp Jul 17, 2026
800407a
Merge branch 'WEB-4654-summary-period' into WEB-4654-time-in-range
henry-tp Jul 17, 2026
3963a49
WEB-4654 fix tests for WEB-4654-time-in-range
henry-tp Jul 17, 2026
b7c5d26
WEB-4654 fix tests
henry-tp Jul 17, 2026
c857c03
Merge branch 'WEB-4654-summary-period' into WEB-4654-time-in-range
henry-tp Jul 19, 2026
3ec296b
WEB-4654 use MemoryRouter instead of mocking out location hook
henry-tp Jul 19, 2026
f4768d4
Merge branch 'WEB-4654-summary-period' into WEB-4654-time-in-range
henry-tp Jul 20, 2026
331634c
WEB-4654 revert reverse order of TIR dropdown
henry-tp Jul 22, 2026
69c3cd8
WEB-4654 fix not meeting TIR label
henry-tp Jul 26, 2026
0c205fd
WEB-4654 fix trackMetric calls
henry-tp Jul 26, 2026
7db9b8e
WEB-4654 update pageName
henry-tp Jul 26, 2026
a59de3b
Merge branch 'WEB-4654-summary-period' into WEB-4654-time-in-range
henry-tp Jul 26, 2026
36dab83
Merge branch 'WEB-4654-summary-period' into WEB-4654-time-in-range
henry-tp Jul 26, 2026
741c5f9
WEB-4654 abstract timeInRangeFilterThresholds for consistency
henry-tp Jul 26, 2026
11d2e79
Merge branch 'WEB-4654-summary-period' into WEB-4654-time-in-range
henry-tp Jul 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions __tests__/unit/app/pages/clinicworkspace/ClinicPatients.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,31 @@ describe('ClinicPatients', () => {
expect.any(Function),
);
}, TEST_TIMEOUT_MS);

it('maps an applied time in range filter into the getPatientsForClinic query', async () => {
render(
<MockedProviderWrappers>
<ClinicPatients {...defaultProps} />
</MockedProviderWrappers>
);

// Open the % Time in Range filter dropdown, select a range, and apply.
await userEvent.click(screen.getByRole('button', { name: /Filter by Time in Range/ }));
await userEvent.click(screen.getByRole('checkbox', { name: /Not meeting TIR/ }));
await userEvent.click(screen.getByRole('button', { name: /Apply/ }));

// Selecting ranges scopes the query to standard target ranges and maps each
// selected range into a `cgm.<range>` comparator threshold (fraction of time).
expect(defaultProps.api.clinics.getPatientsForClinic).toHaveBeenLastCalledWith(
'clinicID123',
expect.objectContaining({
omitNonStandardRanges: true,
'cgm.timeInTargetPercent': '<=0.7',
limit: 50, offset: 0, period: '14d', sortType: 'cgm', sort: '-lastData',
}),
expect.any(Function),
);
}, TEST_TIMEOUT_MS);
});

describe('managing sites', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ describe('AppliedFiltersList', () => {
it('removes only the clicked time-in-range value, preserving the others', async () => {
const { setActiveFilters } = renderList({ activeFilters: FULLY_ACTIVE_FILTERS });

await userEvent.click(screen.getByLabelText('Remove %TIR = Meeting Targets filter'));
await userEvent.click(screen.getByLabelText('Remove %TIR = Not meeting TIR filter'));

expect(setActiveFilters).toHaveBeenCalledTimes(1);
expect(setActiveFilters).toHaveBeenCalledWith({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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 { MemoryRouter } from 'react-router-dom';

import { useFlags } from 'launchdarkly-react-client-sdk';
import FilterByTimeInRange from '@app/pages/clinicworkspace/clinicPatientsFilters/FilterByTimeInRange';

jest.mock('launchdarkly-react-client-sdk');

const mockStore = configureStore([thunk]);

describe('FilterByTimeInRange', () => {
let store;

const selectedClinicId = 'clinic123';

const setActiveFilters = jest.fn();

useFlags.mockReturnValue({ showExtremeHigh: false });

const ui = (props = {}) => (
<Provider store={store}>
<MemoryRouter initialEntries={['/clinic-workspace']}>
<FilterByTimeInRange
setActiveFilters={setActiveFilters}
{...props}
/>
</MemoryRouter>
</Provider>
);

const renderComponent = (props = {}) => render(ui(props));

beforeEach(() => {
store = mockStore({
blip: {
selectedClinicId,
clinics: { [selectedClinicId]: { id: selectedClinicId } },
},
});

setActiveFilters.mockClear();
});

describe('handleChange', () => {
it('calls setActiveFilters with the applied time in range filters merged into the existing activeFilters', async () => {
renderComponent({ activeFilters: { timeInRange: [], patientTags: ['tag1'] } });

await userEvent.click(screen.getByRole('button', { name: /Time in Range/ }));
await screen.findByRole('checkbox', { name: /Very High/ });

await userEvent.click(screen.getByRole('checkbox', { name: /Very High/ }));
await userEvent.click(screen.getByRole('checkbox', { name: /Very Low/ }));
await userEvent.click(screen.getByRole('button', { name: 'Apply' }));

expect(setActiveFilters).toHaveBeenCalledTimes(1);
expect(setActiveFilters).toHaveBeenCalledWith({
timeInRange: ['timeInVeryHighPercent', 'timeInVeryLowPercent'],
patientTags: ['tag1'],
});
});
});

describe('timeInRange passthrough', () => {
it('pre-selects the checkboxes matching the active filters', async () => {
renderComponent({ activeFilters: { timeInRange: ['timeInVeryLowPercent'] } });

await userEvent.click(screen.getByRole('button', { name: /Time in Range/ }));
await screen.findByRole('checkbox', { name: /Very Low/ });

expect(screen.getByRole('checkbox', { name: /Very Low/ })).toBeChecked();
expect(screen.getByRole('checkbox', { name: /Very High/ })).not.toBeChecked();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ describe('ActiveFiltersTray', () => {
renderTray({ filters: { ...defaultFilterState, timeInRange: ['timeInTargetPercent'] } });

expect(screen.getByText('with')).toBeInTheDocument();
expect(screen.getByText('%TIR = Meeting Targets')).toBeInTheDocument();
expect(screen.getByText('%TIR = Not meeting TIR')).toBeInTheDocument();
});

it('renders a data-recency filter with its expected label', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
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 { MemoryRouter } from 'react-router-dom';

import { useFlags } from 'launchdarkly-react-client-sdk';
import TimeInRangeFilterDropdown from '@app/pages/clinicworkspace/components/TimeInRangeFilterDropdown';
import { MMOLL_UNITS } from '@app/core/constants';
import { trackMetric as mockTrackMetric } from '../../../../../app/core/metricUtils';

jest.mock('launchdarkly-react-client-sdk');

const mockStore = configureStore([thunk]);

describe('TimeInRangeFilterDropdown', () => {
let store;

const selectedClinicId = 'clinic123';

let onChange = jest.fn();

const ui = (props = {}) => (
<Provider store={store}>
<MemoryRouter initialEntries={['/clinic-workspace']}>
<TimeInRangeFilterDropdown
onChange={onChange}
timeInRange={[]}
{...props}
/>
</MemoryRouter>
</Provider>
);

const renderComponent = (props = {}) => render(ui(props));

beforeEach(() => {
store = mockStore({
blip: {
selectedClinicId,
clinics: { [selectedClinicId]: { id: selectedClinicId } },
},
});

useFlags.mockReturnValue({ showExtremeHigh: false });
onChange.mockClear();
mockTrackMetric.mockClear();
});

describe('filtering for time in range', () => {
it('applies the time in range filters based on checkboxes selected', async () => {
renderComponent({ timeInRange: [] });

// Empty due to no TIR filters applied
expect(screen.queryByLabelText('filter count')).not.toBeInTheDocument();

// Dropdown closed initially
expect(screen.queryByTestId('time-in-range-filter-dropdown')).not.toBeInTheDocument();

// Open the dropdown
await userEvent.click(screen.getByRole('button', { name: /Time in Range/ }));
expect(screen.getByRole('checkbox', { name: /Very High/ })).toBeInTheDocument();

// Nothing selected initially
expect(screen.getByRole('checkbox', { name: /Very High/ })).not.toBeChecked();
expect(screen.getByRole('checkbox', { name: /^High/ })).not.toBeChecked();
expect(screen.getByRole('checkbox', { name: /Not meeting TIR/ })).not.toBeChecked();
expect(screen.getByRole('checkbox', { name: /^Low/ })).not.toBeChecked();
expect(screen.getByRole('checkbox', { name: /Very Low/ })).not.toBeChecked();

// Selecting ranges and applying sets the filter
await userEvent.click(screen.getByRole('checkbox', { name: /Very High/ }));
await userEvent.click(screen.getByRole('checkbox', { name: /Very Low/ }));
await userEvent.click(screen.getByRole('button', { name: /Apply/ }));
expect(onChange).toHaveBeenCalledWith(['timeInVeryHighPercent', 'timeInVeryLowPercent']);

// Dropdown should automatically close
expect(screen.queryByTestId('time-in-range-filter-dropdown')).not.toBeInTheDocument();
});

it('clears the filter', async () => {
renderComponent({ timeInRange: ['timeInVeryHighPercent', 'timeInVeryLowPercent'] });

expect(screen.getByLabelText('filter count')).toHaveTextContent('2');

await userEvent.click(screen.getByRole('button', { name: /Time in Range/ }));
expect(screen.getByRole('checkbox', { name: /Very High/ })).toBeChecked();
expect(screen.getByRole('checkbox', { name: /^High/ })).not.toBeChecked();
expect(screen.getByRole('checkbox', { name: /Not meeting TIR/ })).not.toBeChecked();
expect(screen.getByRole('checkbox', { name: /^Low/ })).not.toBeChecked();
expect(screen.getByRole('checkbox', { name: /Very Low/ })).toBeChecked();

await userEvent.click(screen.getByRole('button', { name: /Clear/ }));
expect(onChange).toHaveBeenCalledWith([]);
expect(screen.queryByRole('checkbox', { name: /Very High/ })).not.toBeInTheDocument();
});

it('shows the highest range option only when the showExtremeHigh flag is set', async () => {
// Hidden when the flag is off
const { rerender } = renderComponent();
await userEvent.click(screen.getByRole('button', { name: /Time in Range/ }));
expect(screen.queryByRole('checkbox', { name: /Highest/ })).not.toBeInTheDocument();
expect(screen.getByRole('checkbox', { name: /Very High/ })).toBeInTheDocument();

// Visible when the flag is on
useFlags.mockReturnValue({ showExtremeHigh: true });
rerender(ui());
expect(screen.getByRole('checkbox', { name: /Highest/ })).toBeInTheDocument();
});

it('removes a range when its checkbox is unchecked', async () => {
renderComponent({ timeInRange: ['timeInVeryHighPercent', 'timeInVeryLowPercent'] });

await userEvent.click(screen.getByRole('button', { name: /Time in Range/ }));
expect(screen.getByRole('checkbox', { name: /Very High/ })).toBeChecked();
expect(screen.getByRole('checkbox', { name: /Very Low/ })).toBeChecked();

// Unchecking a selected range removes it from the applied filter
await userEvent.click(screen.getByRole('checkbox', { name: /Very High/ }));
expect(screen.getByRole('checkbox', { name: /Very High/ })).not.toBeChecked();

await userEvent.click(screen.getByRole('button', { name: /Apply/ }));
expect(onChange).toHaveBeenCalledWith(['timeInVeryLowPercent']);
});

it('applies the "Not meeting TIR" (target) range', async () => {
renderComponent({ timeInRange: [] });

await userEvent.click(screen.getByRole('button', { name: /Time in Range/ }));

// This is the only option using a "Less than" threshold (the rest use "Greater than")
expect(screen.getByText(/Less than 70%/)).toBeInTheDocument();

await userEvent.click(screen.getByRole('checkbox', { name: /Not meeting TIR/ }));
await userEvent.click(screen.getByRole('button', { name: /Apply/ }));
expect(onChange).toHaveBeenCalledWith(['timeInTargetPercent']);
});

it('renders the range definitions in mg/dL units', async () => {
renderComponent({ timeInRange: [] });
await userEvent.click(screen.getByRole('button', { name: /Time in Range/ }));

expect(screen.getByText('Greater than 1% Time <54 mg/dL')).toBeInTheDocument(); // Very Low
expect(screen.getByText('Greater than 4% Time <70 mg/dL')).toBeInTheDocument(); // Low
expect(screen.getByText('Less than 70% Time between 70-180 mg/dL')).toBeInTheDocument(); // Not meeting TIR
expect(screen.getByText('Greater than 25% Time >180 mg/dL')).toBeInTheDocument(); // High
expect(screen.getByText('Greater than 5% Time >250 mg/dL')).toBeInTheDocument(); // Very High
});

it('renders the range definitions in mmol/L units', async () => {
store = mockStore({
blip: {
selectedClinicId,
clinics: { [selectedClinicId]: { id: selectedClinicId, preferredBgUnits: MMOLL_UNITS } },
},
});

renderComponent({ timeInRange: [] });
await userEvent.click(screen.getByRole('button', { name: /Time in Range/ }));

expect(screen.getByText('Greater than 1% Time <3.0 mmol/L')).toBeInTheDocument(); // Very Low
expect(screen.getByText('Greater than 4% Time <3.9 mmol/L')).toBeInTheDocument(); // Low
expect(screen.getByText('Less than 70% Time between 3.9-10.0 mmol/L')).toBeInTheDocument(); // Not meeting TIR
expect(screen.getByText('Greater than 25% Time >10.0 mmol/L')).toBeInTheDocument(); // High
expect(screen.getByText('Greater than 5% Time >13.9 mmol/L')).toBeInTheDocument(); // Very High
});
});
});
11 changes: 11 additions & 0 deletions app/core/clinicUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,17 @@ export const summaryPeriodOptions = [
{ value: '30d', label: t('30 days') },
];

export const timeInRangeFilterThresholds = {
timeInVeryLowPercent: { value: 1, comparator: '>' },
timeInLowPercent: { value: 4, comparator: '>' },
timeInAnyLowPercent: { value: 4, comparator: '>' },
timeInTargetPercent: { value: 70, comparator: '<' },
timeInHighPercent: { value: 25, comparator: '>' },
timeInAnyHighPercent: { value: 25, comparator: '>' },
timeInVeryHighPercent: { value: 5, comparator: '>' },
timeInExtremeHighPercent: { value: 1, comparator: '>' },
};

export const timezoneOptions = map(
timezoneNames,
name => ({ value: name, label: name })
Expand Down
Loading