From 79fa6c6c1cd0f66486804607feadf13ce57803fb Mon Sep 17 00:00:00 2001 From: clintonium-119 Date: Mon, 25 May 2026 10:22:13 -0400 Subject: [PATCH 1/8] Ignore local apo vault --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index a09ffc853b..a7e7fcad71 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,9 @@ locales/*/*_old.json .codegpt *.code-workspace +.pi +.pi-lens +.claude + +# ApoVault — ignore entire vault locally +.apo/ From d30ef824f8b56b2d7e80c4accad9534be282d36d Mon Sep 17 00:00:00 2001 From: clintonium-119 Date: Mon, 25 May 2026 19:44:01 -0400 Subject: [PATCH 2/8] WEB-4595 Migrate pending data source UI to provider connection requests Stop the synthetic `dataSources: [{ state: 'pending' }]` write that the clinician-initiated invite flow sent through updateClinicPatient. Derive pending / pendingReconnect / pendingExpired UI states from patient.connectionRequests[providerName] joined with patient.dataSources via a new shared resolveConnectState helper, per the BACK-4414 contract. - DataConnections: drop patientUpdates from send/resend handlers; add resolveConnectState (pending/pendingReconnect/pendingExpired/connected/ error/disconnected) and route the UI through it. - TideDashboard, ClinicPatients: read connection status via the new helper instead of inspecting dataSources state strings directly. - clinicUtils: remove orphaned dataSources + connectDexcom validation from patientSchema (no caller produces those fields). - Tests + storybook fixtures updated to the connectionRequests shape; legacy pending-state assertions retired. --- .../clinic/PatientForm/PatientForm.js | 1 - app/components/datasources/DataConnections.js | 144 ++++++------- app/core/clinicUtils.js | 7 - app/pages/dashboard/TideDashboard.js | 15 +- stories/DataConnection.stories.js | 53 +++-- test/fixtures/mockTideDashboardPatients.json | 20 +- test/unit/app/core/clinicUtils.test.js | 2 - test/unit/components/chart/settings.test.js | 4 +- .../datasources/DataConnections.test.js | 201 ++++++++++++------ .../datasources/PatientEmailModal.test.js | 2 - test/unit/pages/ClinicPatients.test.js | 22 +- 11 files changed, 277 insertions(+), 194 deletions(-) diff --git a/app/components/clinic/PatientForm/PatientForm.js b/app/components/clinic/PatientForm/PatientForm.js index c1d86f3e70..087a75ccb2 100644 --- a/app/components/clinic/PatientForm/PatientForm.js +++ b/app/components/clinic/PatientForm/PatientForm.js @@ -38,7 +38,6 @@ export function getFormValues(source, clinicPatientTags, clinicSites) { fullName: source?.fullName || '', mrn: source?.mrn || '', tags: reject(source?.tags || [], tagId => !clinicPatientTags?.[tagId]), - dataSources: source?.dataSources || [], sites: source?.sites?.filter(site => !!clinicSites[site.id]) || [], diagnosisType: source?.diagnosisType || '', glycemicRanges: source?.glycemicRanges || DEFAULT_GLYCEMIC_RANGES, diff --git a/app/components/datasources/DataConnections.js b/app/components/datasources/DataConnections.js index 709ef5f43c..57a31f073b 100644 --- a/app/components/datasources/DataConnections.js +++ b/app/components/datasources/DataConnections.js @@ -7,7 +7,6 @@ import CheckRoundedIcon from '@material-ui/icons/CheckRounded'; import moment from 'moment-timezone'; import defaults from 'lodash/defaults'; import filter from 'lodash/filter'; -import find from 'lodash/find'; import get from 'lodash/get'; import includes from 'lodash/includes'; import intersection from 'lodash/intersection'; @@ -107,21 +106,9 @@ export function getProviderHandlers(patient, selectedClinicId, provider) { const { id, restrictedTokenCreate, dataSourceFilter } = provider; const providerName = dataSourceFilter?.providerName; - // Clinician-initiated send and resend invite handlers will potentially need to gather an email - // address and set the initial data source pending status on the patient if these do not exist. + // Clinician-initiated send and resend invite handlers may need to gather a patient email address + // before the connection request can be sent. const emailRequired = !!(selectedClinicId && !patient?.email && patient?.permissions?.custodian); - const hasProviderDataSource = !!find(patient?.dataSources, { providerName }); - - let patientUpdates; - - if (!hasProviderDataSource) { - patientUpdates = { - dataSources: [ - ...patient?.dataSources || [], - { providerName, state: 'pending' }, - ], - }; - } return { connect: { @@ -156,7 +143,6 @@ export function getProviderHandlers(patient, selectedClinicId, provider) { action: actions.async.sendPatientDataProviderConnectRequest, args: [api, selectedClinicId, patient?.id, providerName], emailRequired, - patientUpdates, }, resendInvite: { buttonText: t('Resend Invite'), @@ -164,7 +150,6 @@ export function getProviderHandlers(patient, selectedClinicId, provider) { action: actions.async.sendPatientDataProviderConnectRequest, args: [api, selectedClinicId, patient?.id, providerName], emailRequired, - patientUpdates, }, } }; @@ -178,11 +163,9 @@ export const getCurrentDataSourceForProvider = (patient, providerName) => { // Define state priority order const statePriority = { - pending: 1, - connected: 2, - error: 3, - pendingReconnect: 4, - disconnected: 5, + connected: 1, + error: 2, + disconnected: 3, }; // Sort by state priority, then by lastImportTime (descending) for disconnected states @@ -194,6 +177,50 @@ export const getCurrentDataSourceForProvider = (patient, providerName) => { return sortedDataSources[0]; }; +/** + * Derive a single connectState identifier for a provider by joining + * patient.dataSources with patient.connectionRequests[providerName] per the + * BACK-4414 / WEB-4595 contract: + * + * - pending = non-expired connectionRequest, no dataSource + * - pendingReconnect = non-expired connectionRequest + non-connected dataSource + * whose modifiedTime predates the request's createdTime + * - pendingExpired = expired connectionRequest + no connected dataSource + * - connected = dataSource state === 'connected' + * - disconnected = dataSource state === 'disconnected' + * - error = dataSource state === 'error' + * - unknown = dataSource state is none of the above + * - noPendingConnections = no dataSource and no connectionRequest + * + * @param {Object} patient + * @param {String} providerName + * @param {String} [now] - ISO 8601 timestamp; defaults to moment.utc().toISOString() + */ +export const resolveConnectState = (patient, providerName, now = moment.utc().toISOString()) => { + const dataSource = getCurrentDataSourceForProvider(patient, providerName); + const connectionRequest = patient?.connectionRequests?.[providerName]?.[0]; + const requestExpired = !!connectionRequest?.expirationTime + && moment(connectionRequest.expirationTime).isBefore(now); + const requestNewerThanDataSource = !!connectionRequest?.createdTime + && (!dataSource?.modifiedTime + || moment(connectionRequest.createdTime).isAfter(dataSource.modifiedTime)); + + if (connectionRequest && !requestExpired) { + if (!dataSource) return 'pending'; + if (dataSource.state !== 'connected' && requestNewerThanDataSource) return 'pendingReconnect'; + } + + if (connectionRequest && requestExpired && (!dataSource || dataSource.state !== 'connected')) { + return 'pendingExpired'; + } + + if (!dataSource) return 'noPendingConnections'; + + return includes(['connected', 'disconnected', 'error'], dataSource.state) + ? dataSource.state + : 'unknown'; +}; + export const getConnectStateUI = (patient, isLoggedInUser, providerName) => { const dataSource = getCurrentDataSourceForProvider(patient, providerName); @@ -309,26 +336,11 @@ export const getConnectStateUI = (patient, isLoggedInUser, providerName) => { export const getDataConnectionProps = (patient, isLoggedInUser, selectedClinicId, setActiveHandler) => reduce(availableProviders, (result, providerName) => { result[providerName] = {}; - let connectState; - - const dataSource = getCurrentDataSourceForProvider(patient, providerName); const connectStateUI = getConnectStateUI(patient, isLoggedInUser, providerName); - const inviteExpired = dataSource?.expirationTime < moment.utc().toISOString(); - - if (dataSource?.state) { - connectState = includes(keys(connectStateUI), dataSource.state) - ? dataSource.state - : 'unknown'; - - if (includes(['pending', 'pendingReconnect'], connectState)) { - if (inviteExpired) { - connectState = 'pendingExpired'; - } else if (connectStateUI[connectState].inviteJustSent) { - connectState = 'inviteJustSent'; - } - } - } else { - connectState = 'noPendingConnections'; + let connectState = resolveConnectState(patient, providerName); + + if (includes(['pending', 'pendingReconnect'], connectState) && connectStateUI[connectState]?.inviteJustSent) { + connectState = 'inviteJustSent'; } const { color, icon, message, text, handler } = connectStateUI[connectState]; @@ -341,13 +353,12 @@ export const getDataConnectionProps = (patient, isLoggedInUser, selectedClinicId buttonText, buttonStyle, emailRequired, - patientUpdates, } = getProviderHandlers(patient, selectedClinicId, providers[providerName])[handler] || {}; if (action) { result[providerName].buttonDisabled = buttonDisabled; result[providerName].buttonIcon = buttonIcon; - result[providerName].buttonHandler = () => setActiveHandler({ action, args, emailRequired, patientUpdates, providerName, connectState, handler }); + result[providerName].buttonHandler = () => setActiveHandler({ action, args, emailRequired, providerName, connectState, handler }); result[providerName].buttonText = buttonText; result[providerName].buttonStyle = buttonStyle; } @@ -384,7 +395,6 @@ export const DataConnections = (props) => { const [showPatientEmailModal, setShowPatientEmailModal] = useState(false); const [patientEmailFormContext, setPatientEmailFormContext] = useState(); const [processingEmailUpdate, setProcessingEmailUpdate] = useState(false); - const [patientUpdates, setPatientUpdates] = useState({}); const [activeHandler, setActiveHandler] = useState(null); const dataConnectionProps = getDataConnectionProps(patient, isLoggedInUser, selectedClinicId, setActiveHandler); const activeProviders = getActiveProviders(); @@ -434,7 +444,6 @@ export const DataConnections = (props) => { setShowPatientEmailModal(false); setProcessingEmailUpdate(false); - setPatientUpdates({}); setActiveHandler(null); } } @@ -470,18 +479,12 @@ export const DataConnections = (props) => { fetchPatientDetails(); setShowPatientEmailModal(false); setProcessingEmailUpdate(false); - setPatientUpdates({}); - - if (activeHandler?.action) { - if (activeHandler?.emailRequired) { - // Immediately after adding a new patient email address. There will be a small amount - // of time where the backend services may not be able to find the patient, so we wait - // a second before requesting that a connection request email be sent. - setTimeout(() => dispatch(activeHandler.action(...activeHandler.args)), 1000); - } else { - // If we haven't just added an email to a patient, we can fire this right away. - dispatch(activeHandler.action(...activeHandler.args)); - } + + if (activeHandler?.action && activeHandler?.emailRequired) { + // Immediately after adding a new patient email address. There will be a small amount + // of time where the backend services may not be able to find the patient, so we wait + // a second before requesting that a connection request email be sent. + setTimeout(() => dispatch(activeHandler.action(...activeHandler.args)), 1000); } }, [ activeHandler, @@ -550,17 +553,12 @@ export const DataConnections = (props) => { setActiveHandler({ ...activeHandler, inProgress: true }); if (activeHandler.emailRequired) { - // Store any patient updates in state. We will collect the email address, and then add it - // to the updates obect before applying them. - setPatientUpdates(activeHandler.patientUpdates || {}); + // Custodial patient is missing an email; gather it via PatientEmailModal, then fire the + // connection request from handleUpdatePatientComplete. handleAddPatientEmailOpen(); - } else if (patient && activeHandler.patientUpdates) { - // We have updates to apply before we can fire the data connection action. - dispatch(actions.async.updateClinicPatient(api, selectedClinicId, patient.id, { ...patient, ...activeHandler.patientUpdates })); } else if (activeHandler.handler === 'resendInvite') { handleResendDataSourceConnectEmailOpen(); } else { - // No need to update patient object prior to firing data connection action. Fire away. dispatch(activeHandler.action(...activeHandler.args)); } } @@ -569,8 +567,6 @@ export const DataConnections = (props) => { dispatch, handleAddPatientEmailOpen, handleResendDataSourceConnectEmailOpen, - patient, - selectedClinicId, ]); useEffect(() => { @@ -632,7 +628,7 @@ export const DataConnections = (props) => { onClose={handleAddPatientEmailClose} onFormChange={handleAddPatientEmailFormChange} onSubmit={handleAddPatientEmailConfirm} - patient={{ ...patient, ...patientUpdates }} + patient={patient} processing={processingEmailUpdate} trackMetric={trackMetric} />} @@ -659,10 +655,9 @@ export const DataConnections = (props) => { }; const clinicPatientDataSourceShape = { - expirationTime: PropTypes.string, modifiedTime: PropTypes.string, providerName: PropTypes.string.isRequired, - state: PropTypes.oneOf(['connected', 'disconnected', 'error', 'pending', 'pendingReconnect']).isRequired, + state: PropTypes.oneOf(['connected', 'disconnected', 'error']).isRequired, }; const userDataSourceShape = { @@ -671,13 +666,20 @@ const userDataSourceShape = { latestDataTime: PropTypes.string, modifiedTime: PropTypes.string, providerName: PropTypes.string.isRequired, - state: PropTypes.oneOf(['connected', 'disconnected', 'error', 'pending', 'pendingReconnect']).isRequired, + state: PropTypes.oneOf(['connected', 'disconnected', 'error']).isRequired, +}; + +const connectionRequestShape = { + providerName: PropTypes.string.isRequired, + createdTime: PropTypes.string.isRequired, + expirationTime: PropTypes.string, }; DataConnections.propTypes = { ...BoxProps, patient: PropTypes.shape({ - dataSources: PropTypes.oneOf([PropTypes.shape(clinicPatientDataSourceShape), PropTypes.shape(userDataSourceShape)]) + dataSources: PropTypes.oneOf([PropTypes.shape(clinicPatientDataSourceShape), PropTypes.shape(userDataSourceShape)]), + connectionRequests: PropTypes.objectOf(PropTypes.arrayOf(PropTypes.shape(connectionRequestShape))), }), shownProviders: PropTypes.arrayOf(PropTypes.oneOf(availableProviders)), trackMetric: PropTypes.func.isRequired, diff --git a/app/core/clinicUtils.js b/app/core/clinicUtils.js index ee925e273b..e12773c4e3 100644 --- a/app/core/clinicUtils.js +++ b/app/core/clinicUtils.js @@ -435,13 +435,6 @@ export const patientSchema = config => { .required(t('Patient\'s birthday is required')), mrn: mrnSchema, email: yup.string().email(t('Please enter a valid email address')), - connectDexcom: yup.boolean(), - dataSources: yup.array().of( - yup.object().shape({ - providerName: yup.string(), - state: yup.string().oneOf(['pending', 'pendingReconnect', 'connected', 'error', 'disconnected']), - }), - ), tags: yup.array().of( yup.string() ), diff --git a/app/pages/dashboard/TideDashboard.js b/app/pages/dashboard/TideDashboard.js index 1daec2913b..b714eb24c1 100644 --- a/app/pages/dashboard/TideDashboard.js +++ b/app/pages/dashboard/TideDashboard.js @@ -53,6 +53,7 @@ import PatientForm from '../../components/clinic/PatientForm'; import TideDashboardConfigForm, { validateTideConfig } from '../../components/clinic/TideDashboardConfigForm'; import BgSummaryCell from '../../components/clinic/BgSummaryCell'; import DataConnectionsModal from '../../components/datasources/DataConnectionsModal'; +import { resolveConnectState } from '../../components/datasources/DataConnections'; import Popover from '../../components/elements/Popover'; import PopoverMenu from '../../components/elements/PopoverMenu'; import RadioGroup from '../../components/elements/RadioGroup'; @@ -564,19 +565,7 @@ const TideDashboardSection = React.memo(props => { ]); const renderDexcomConnectionStatus = useCallback(({ patient }) => { - const dexcomDataSource = find(patient?.dataSources, { providerName: 'dexcom' }); - const dexcomAuthInviteExpired = dexcomDataSource?.expirationTime < moment.utc().toISOString(); - let dexcomConnectState; - - if (dexcomDataSource) { - dexcomConnectState = includes(keys(dexcomConnectStateUI), dexcomDataSource?.state) - ? dexcomDataSource.state - : 'unknown'; - - if (includes(['pending', 'pendingReconnect'], dexcomConnectState) && dexcomAuthInviteExpired) dexcomConnectState = 'pendingExpired'; - } else { - dexcomConnectState = 'noPendingConnections'; - } + const dexcomConnectState = resolveConnectState(patient, 'dexcom'); const showViewButton = includes([ 'disconnected', diff --git a/stories/DataConnection.stories.js b/stories/DataConnection.stories.js index 27853fab02..247e0d6b40 100644 --- a/stories/DataConnection.stories.js +++ b/stories/DataConnection.stories.js @@ -2,6 +2,7 @@ import React from 'react'; import { action } from '@storybook/addon-actions'; import { ThemeProvider } from '@emotion/react'; import moment from 'moment-timezone'; +import includes from 'lodash/includes'; import map from 'lodash/map'; import noop from 'lodash/noop'; @@ -27,22 +28,42 @@ export default { decorators: [withTheme], }; -const patientWithState = (isClinicContext, state, opts = {}) => ({ - id: 'patient123', - dataSources: state ? map(availableProviders, providerName => ({ - providerName, - state, - createdTime: opts.createdTime, - modifiedTime: opts.modifiedTime, - expirationTime: opts.expirationTime, - lastImportTime: opts.lastImportTime, - latestDataTime: opts.latestDataTime, - })) : undefined, - connectionRequests: isClinicContext && opts.createdTime ? reduce(availableProviders, (res, providerName) => { - res[providerName] = [{ providerName, createdTime: opts.createdTime }]; - return res; - }, {}) : undefined, -}); +// The invite lifecycle lives on patient.connectionRequests; established +// connections live on patient.dataSources. Stories using a `pending` / `pendingReconnect` +// / `pendingExpired` state pass through the connectionRequests path. `pendingReconnect` +// additionally seeds a non-connected dataSource whose modifiedTime predates the request. +const requestDrivenStates = ['pending', 'pendingReconnect', 'pendingExpired']; + +const patientWithState = (isClinicContext, state, opts = {}) => { + const hasDataSource = !!state && !includes(['pending', 'pendingExpired'], state); + const dataSourceState = state === 'pendingReconnect' ? 'disconnected' : state; + const dataSourceModifiedTime = state === 'pendingReconnect' + ? moment.utc(opts.createdTime).subtract(1, 'day').toISOString() + : opts.modifiedTime; + const hasConnectionRequest = isClinicContext && includes(requestDrivenStates, state); + const requestExpirationTime = opts.expirationTime + || (opts.createdTime ? moment.utc(opts.createdTime).add(30, 'days').toISOString() : undefined); + + return { + id: 'patient123', + dataSources: hasDataSource ? map(availableProviders, providerName => ({ + providerName, + state: dataSourceState, + createdTime: opts.createdTime, + modifiedTime: dataSourceModifiedTime, + lastImportTime: opts.lastImportTime, + latestDataTime: opts.latestDataTime, + })) : undefined, + connectionRequests: hasConnectionRequest ? reduce(availableProviders, (res, providerName) => { + res[providerName] = [{ + providerName, + createdTime: opts.createdTime, + expirationTime: requestExpirationTime, + }]; + return res; + }, {}) : undefined, + }; +}; const getDateInPast = (amount, unit) => moment.utc().subtract(amount, unit).toISOString(); diff --git a/test/fixtures/mockTideDashboardPatients.json b/test/fixtures/mockTideDashboardPatients.json index acc8fd2eef..aa7687e739 100644 --- a/test/fixtures/mockTideDashboardPatients.json +++ b/test/fixtures/mockTideDashboardPatients.json @@ -657,9 +657,13 @@ "646f7ed208e23bc18d91caa3", "646f7ed708e23bc18d91caa4" ], - "dataSources": [ - { "providerName": "dexcom", "state": "pending", "expirationTime": "2024-11-17T15:17:20.159+00:00" } - ] + "connectionRequests": { + "dexcom": [{ + "providerName": "dexcom", + "createdTime": "2024-10-18T15:17:20.159+00:00", + "expirationTime": "2024-11-17T15:17:20.159+00:00" + }] + } } }, { @@ -684,9 +688,13 @@ "6455353c299ee2ccddbdbe94", "646f7f0408e23bc18d91caa7" ], - "dataSources": [ - { "providerName": "dexcom", "state": "pending" } - ] + "connectionRequests": { + "dexcom": [{ + "providerName": "dexcom", + "createdTime": "2099-01-01T00:00:00.000+00:00", + "expirationTime": "2099-12-31T00:00:00.000+00:00" + }] + } } } ] diff --git a/test/unit/app/core/clinicUtils.test.js b/test/unit/app/core/clinicUtils.test.js index 9b4fd83e1c..7a599a82eb 100644 --- a/test/unit/app/core/clinicUtils.test.js +++ b/test/unit/app/core/clinicUtils.test.js @@ -756,8 +756,6 @@ describe('clinicUtils', function() { 'mrn', 'sites', 'tags', - 'connectDexcom', - 'dataSources', 'glycemicRanges', 'diagnosisType', ]); diff --git a/test/unit/components/chart/settings.test.js b/test/unit/components/chart/settings.test.js index 2cf546f6b1..09b09ad85e 100644 --- a/test/unit/components/chart/settings.test.js +++ b/test/unit/components/chart/settings.test.js @@ -1443,7 +1443,7 @@ describe('Settings', () => { isUserPatient: false, clinicPatient: { ...clinicPatient, - dataSources: _.map(availableProviders, providerName => ({ providerName, state: 'pending' })), + dataSources: _.map(availableProviders, providerName => ({ providerName, state: 'connected' })), }, }; @@ -1545,7 +1545,7 @@ describe('Settings', () => { const state = { blip: { ...defaultState.blip, - dataSources: _.map(availableProviders, providerName => ({ providerName, state: 'pending' })), + dataSources: _.map(availableProviders, providerName => ({ providerName, state: 'connected' })), } }; diff --git a/test/unit/components/datasources/DataConnections.test.js b/test/unit/components/datasources/DataConnections.test.js index 290842bc13..188a648626 100644 --- a/test/unit/components/datasources/DataConnections.test.js +++ b/test/unit/components/datasources/DataConnections.test.js @@ -6,6 +6,7 @@ import { Provider } from 'react-redux'; import { thunk } from 'redux-thunk'; import CheckRoundedIcon from '@material-ui/icons/CheckRounded'; import { ToastProvider } from '../../../../app/providers/ToastProvider'; +import includes from 'lodash/includes'; import map from 'lodash/map'; import reduce from 'lodash/reduce'; import coreApi from '../../../../app/core/api'; @@ -22,7 +23,8 @@ import DataConnections, { getProviderHandlers, getCurrentDataSourceForProvider, getConnectStateUI, - getDataConnectionProps + getDataConnectionProps, + resolveConnectState, } from '../../../../app/components/datasources/DataConnections'; /* global chai */ @@ -141,7 +143,6 @@ describe('getProviderHandlers', () => { action: appActions.async.sendPatientDataProviderConnectRequest, args: [coreApi, 'clinic123', 'patient123', 'provider123'], emailRequired: false, - patientUpdates: undefined, }, resendInvite: { buttonText: 'Resend Invite', @@ -149,7 +150,6 @@ describe('getProviderHandlers', () => { action: appActions.async.sendPatientDataProviderConnectRequest, args: [coreApi, 'clinic123', 'patient123', 'provider123'], emailRequired: false, - patientUpdates: undefined, }, }); }); @@ -163,15 +163,79 @@ describe('getProviderHandlers', () => { expect(handlers.sendInvite.emailRequired).to.be.true; expect(handlers.resendInvite.emailRequired).to.be.true; }); +}); - it('should set patientUpdates to include pending data source for send and resend invite actions if patient does not have a data source for the provider', () => { - const patient = { id: 'patient123', email: 'patient@123.com', permissions: { custodian: {} }, dataSources: [ { providerName: 'otherProvider' }] }; - const selectedClinicId = 'clinic123'; - const provider = { id: 'oauth/provider123', dataSourceFilter: { providerName: 'provider123' }, restrictedTokenCreate: { paths: ['/v1/oauth/provider123'] }}; +describe('resolveConnectState', () => { + const NOW = '2026-05-25T12:00:00Z'; + const past = (days) => moment.utc(NOW).subtract(days, 'days').toISOString(); + const future = (days) => moment.utc(NOW).add(days, 'days').toISOString(); - const handlers = getProviderHandlers(patient, selectedClinicId, provider); - expect(handlers.sendInvite.patientUpdates).to.eql({ dataSources: [ { providerName: 'otherProvider' }, { providerName: 'provider123', state: 'pending' } ]}); - expect(handlers.resendInvite.patientUpdates).to.eql({ dataSources: [ { providerName: 'otherProvider' }, { providerName: 'provider123', state: 'pending' } ]}); + it('returns noPendingConnections when no dataSource and no connectionRequest', () => { + const patient = { dataSources: [], connectionRequests: {} }; + expect(resolveConnectState(patient, 'dexcom', NOW)).to.equal('noPendingConnections'); + }); + + it('returns pending when a non-expired connectionRequest exists and no dataSource', () => { + const patient = { + connectionRequests: { dexcom: [{ providerName: 'dexcom', createdTime: past(2), expirationTime: future(28) }] }, + }; + expect(resolveConnectState(patient, 'dexcom', NOW)).to.equal('pending'); + }); + + it('returns pendingExpired when a connectionRequest exists and is expired and no connected dataSource', () => { + const patient = { + connectionRequests: { dexcom: [{ providerName: 'dexcom', createdTime: past(31), expirationTime: past(1) }] }, + }; + expect(resolveConnectState(patient, 'dexcom', NOW)).to.equal('pendingExpired'); + }); + + it('returns pendingReconnect when a non-expired connectionRequest exists alongside a non-connected dataSource whose modifiedTime predates the request', () => { + const patient = { + dataSources: [{ providerName: 'dexcom', state: 'disconnected', modifiedTime: past(10) }], + connectionRequests: { dexcom: [{ providerName: 'dexcom', createdTime: past(2), expirationTime: future(28) }] }, + }; + expect(resolveConnectState(patient, 'dexcom', NOW)).to.equal('pendingReconnect'); + }); + + it('falls through to dataSource.state when connectionRequest.createdTime predates dataSource.modifiedTime', () => { + const patient = { + dataSources: [{ providerName: 'dexcom', state: 'disconnected', modifiedTime: past(1) }], + connectionRequests: { dexcom: [{ providerName: 'dexcom', createdTime: past(10), expirationTime: future(20) }] }, + }; + expect(resolveConnectState(patient, 'dexcom', NOW)).to.equal('disconnected'); + }); + + it('returns connected when dataSource is connected, even with a recent connectionRequest', () => { + const patient = { + dataSources: [{ providerName: 'dexcom', state: 'connected', modifiedTime: past(1) }], + connectionRequests: { dexcom: [{ providerName: 'dexcom', createdTime: past(2), expirationTime: future(28) }] }, + }; + expect(resolveConnectState(patient, 'dexcom', NOW)).to.equal('connected'); + }); + + it('does not surface pendingExpired when dataSource is connected', () => { + const patient = { + dataSources: [{ providerName: 'dexcom', state: 'connected', modifiedTime: past(1) }], + connectionRequests: { dexcom: [{ providerName: 'dexcom', createdTime: past(60), expirationTime: past(30) }] }, + }; + expect(resolveConnectState(patient, 'dexcom', NOW)).to.equal('connected'); + }); + + it('returns disconnected / error when no connectionRequest is present', () => { + expect(resolveConnectState({ + dataSources: [{ providerName: 'dexcom', state: 'disconnected', modifiedTime: past(1) }], + }, 'dexcom', NOW)).to.equal('disconnected'); + + expect(resolveConnectState({ + dataSources: [{ providerName: 'dexcom', state: 'error', modifiedTime: past(1) }], + }, 'dexcom', NOW)).to.equal('error'); + }); + + it('treats a missing expirationTime as "not expired"', () => { + const patient = { + connectionRequests: { dexcom: [{ providerName: 'dexcom', createdTime: past(60) }] }, + }; + expect(resolveConnectState(patient, 'dexcom', NOW)).to.equal('pending'); }); }); @@ -192,25 +256,10 @@ describe('getCurrentDataSourceForProvider', () => { expect(result).to.be.undefined; }); - it('should return pending data source as highest priority', () => { - const patient = { - dataSources: [ - { providerName: 'dexcom', state: 'disconnected', lastImportTime: '2024-02-01T00:00:00Z' }, - { providerName: 'dexcom', state: 'pendingReconnect' }, - { providerName: 'dexcom', state: 'error' }, - { providerName: 'dexcom', state: 'connected' }, - { providerName: 'dexcom', state: 'pending' }, - ], - }; - const result = getCurrentDataSourceForProvider(patient, 'dexcom'); - expect(result.state).to.equal('pending'); - }); - - it('should return connected data source when no pending exists', () => { + it('should return connected data source as highest priority', () => { const patient = { dataSources: [ { providerName: 'dexcom', state: 'disconnected', lastImportTime: '2024-02-01T00:00:00Z' }, - { providerName: 'dexcom', state: 'pendingReconnect' }, { providerName: 'dexcom', state: 'error' }, { providerName: 'dexcom', state: 'connected' }, ], @@ -219,11 +268,10 @@ describe('getCurrentDataSourceForProvider', () => { expect(result.state).to.equal('connected'); }); - it('should return error data source when no pending or connected exists', () => { + it('should return error data source when no connected exists', () => { const patient = { dataSources: [ { providerName: 'dexcom', state: 'disconnected', lastImportTime: '2024-02-01T00:00:00Z' }, - { providerName: 'dexcom', state: 'pendingReconnect' }, { providerName: 'dexcom', state: 'error' }, ], }; @@ -231,17 +279,6 @@ describe('getCurrentDataSourceForProvider', () => { expect(result.state).to.equal('error'); }); - it('should return pendingReconnect data source when no pending, connected, or error exists', () => { - const patient = { - dataSources: [ - { providerName: 'dexcom', state: 'disconnected', lastImportTime: '2024-02-01T00:00:00Z' }, - { providerName: 'dexcom', state: 'pendingReconnect' }, - ], - }; - const result = getCurrentDataSourceForProvider(patient, 'dexcom'); - expect(result.state).to.equal('pendingReconnect'); - }); - it('should return the disconnected data source with the most recent lastImportTime when only disconnected states exist', () => { const patient = { dataSources: [ @@ -289,9 +326,14 @@ describe('getCurrentDataSourceForProvider', () => { }); describe('getConnectStateUI', () => { + // getConnectStateUI returns the full UI map keyed by connectState identifier; the actual + // resolved state for a patient is the consumer's concern. These fixtures supply enough + // timing context for the UI map's messages to render. Post-BACK-4414, dataSource.state + // is one of ['connected', 'disconnected', 'error'] — 'disconnected' is the inert choice + // here since the fixtures aren't exercising state-driven branches. const clinicPatient = { id: 'patient123', - dataSources: [ { providerName: 'provider123', state: 'pending' }], + dataSources: [ { providerName: 'provider123', state: 'disconnected' }], connectionRequests: { provider123: [{ createdTime: moment.utc().subtract(20, 'days') }] }, }; @@ -304,7 +346,7 @@ describe('getConnectStateUI', () => { id: 'patient123', dataSources: [ { providerName: 'provider123', - state: 'pending', + state: 'disconnected', createdTime: moment.utc().subtract(20, 'days'), }], } @@ -313,7 +355,7 @@ describe('getConnectStateUI', () => { id: 'patient123', dataSources: [ { providerName: 'provider123', - state: 'pending', + state: 'disconnected', createdTime: moment.utc().subtract(20, 'days'), lastImportTime: moment.utc().subtract(10, 'days'), }], @@ -323,7 +365,7 @@ describe('getConnectStateUI', () => { id: 'patient123', dataSources: [ { providerName: 'provider123', - state: 'pending', + state: 'disconnected', createdTime: moment.utc().subtract(20, 'days'), modifiedTime: moment.utc().subtract(5, 'days'), lastImportTime: moment.utc().subtract(10, 'days'), @@ -428,17 +470,16 @@ describe('getConnectStateUI', () => { describe('getDataConnectionProps', () => { const setActiveHandlerStub = sinon.stub(); + // Pending state lives on connectionRequests, not on dataSources. const createPatientWithPendingDexcomConnection = () => ({ id: 'patient123', - dataSources: [ - { + connectionRequests: { + dexcom: [{ providerName: 'dexcom', - state: 'pending', - modifiedTime: moment.utc().subtract(2, 'days').toISOString(), - expirationTime: moment.utc().add(5, 'days').toISOString(), - }, - ], - connectionRequests: { dexcom: [{ createdTime: moment.utc().subtract(2, 'days').toISOString() }] }, + createdTime: moment.utc().subtract(2, 'days').toISOString(), + expirationTime: moment.utc().add(28, 'days').toISOString(), + }], + }, }); afterEach(() => { @@ -474,7 +515,6 @@ describe('getDataConnectionProps', () => { action: appActions.async.sendPatientDataProviderConnectRequest, args: [coreApi, 'clinic125', 'patient123', 'dexcom'], emailRequired: false, - patientUpdates: undefined, providerName: 'dexcom', connectState: 'pending', handler: 'resendInvite', @@ -511,22 +551,41 @@ describe('DataConnections', () => { const getDateInPast = (amount, unit) => moment.utc().subtract(amount, unit).toISOString(); - const patientWithState = (isClinicContext, state, opts = {}) => ({ - id: 'patient123', - dataSources: state ? map(availableProviders, providerName => ({ - providerName, - state, - createdTime: opts.createdTime, - modifiedTime: opts.modifiedTime, - expirationTime: opts.expirationTime, - lastImportTime: opts.lastImportTime, - latestDataTime: opts.latestDataTime, - })) : undefined, - connectionRequests: isClinicContext && opts.createdTime ? reduce(availableProviders, (res, providerName) => { - res[providerName] = [{ providerName, createdTime: opts.createdTime }]; - return res; - }, {}) : undefined, - }); + // Pending lifecycle is on patient.connectionRequests; established + // connections are on patient.dataSources. `pendingReconnect` carries both: a non- + // connected dataSource with modifiedTime older than the request's createdTime. + const requestDrivenStates = ['pending', 'pendingReconnect', 'pendingExpired']; + + const patientWithState = (isClinicContext, state, opts = {}) => { + const hasDataSource = !!state && !includes(['pending', 'pendingExpired'], state); + const dataSourceState = state === 'pendingReconnect' ? 'disconnected' : state; + const dataSourceModifiedTime = state === 'pendingReconnect' + ? moment.utc(opts.createdTime).subtract(1, 'day').toISOString() + : opts.modifiedTime; + const hasConnectionRequest = isClinicContext && includes(requestDrivenStates, state); + const requestExpirationTime = opts.expirationTime + || (opts.createdTime ? moment.utc(opts.createdTime).add(30, 'days').toISOString() : undefined); + + return { + id: 'patient123', + dataSources: hasDataSource ? map(availableProviders, providerName => ({ + providerName, + state: dataSourceState, + createdTime: opts.createdTime, + modifiedTime: dataSourceModifiedTime, + lastImportTime: opts.lastImportTime, + latestDataTime: opts.latestDataTime, + })) : undefined, + connectionRequests: hasConnectionRequest ? reduce(availableProviders, (res, providerName) => { + res[providerName] = [{ + providerName, + createdTime: opts.createdTime, + expirationTime: requestExpirationTime, + }]; + return res; + }, {}) : undefined, + }; + }; let clinicPatients; let userPatients; @@ -675,9 +734,11 @@ describe('DataConnections', () => { fireEvent.click(twiistActionButton); await waitFor(() => { - sinon.assert.calledWith(api.clinics.updateClinicPatient, 'clinicID123', 'patient123', sinon.match({ dataSources: [ { providerName: 'dexcom', state: 'pending' } ] })); - sinon.assert.calledWith(api.clinics.updateClinicPatient, 'clinicID123', 'patient123', sinon.match({ dataSources: [ { providerName: 'twiist', state: 'pending' } ] })); + sinon.assert.calledWith(api.clinics.sendPatientDataProviderConnectRequest, 'clinicID123', 'patient123', 'dexcom'); + sinon.assert.calledWith(api.clinics.sendPatientDataProviderConnectRequest, 'clinicID123', 'patient123', 'twiist'); }); + // No synthetic dataSources write — the connect-request endpoint is authoritative. + sinon.assert.notCalled(api.clinics.updateClinicPatient); }); }); diff --git a/test/unit/components/datasources/PatientEmailModal.test.js b/test/unit/components/datasources/PatientEmailModal.test.js index c3711c2f6d..84c1dca00f 100644 --- a/test/unit/components/datasources/PatientEmailModal.test.js +++ b/test/unit/components/datasources/PatientEmailModal.test.js @@ -106,7 +106,6 @@ describe('PatientEmailModal', () => { fullName: 'Patient 123', mrn: '12345', tags: ['tag1', 'tag2'], - dataSources: [], } })); store.clearActions(); @@ -150,7 +149,6 @@ describe('PatientEmailModal', () => { fullName: 'Patient 123', mrn: '12345', tags: ['tag1', 'tag2'], - dataSources: [], } })); store.clearActions(); diff --git a/test/unit/pages/ClinicPatients.test.js b/test/unit/pages/ClinicPatients.test.js index bcdf631acd..1614e3ea85 100644 --- a/test/unit/pages/ClinicPatients.test.js +++ b/test/unit/pages/ClinicPatients.test.js @@ -293,9 +293,14 @@ describe('ClinicPatients', () => { fullName: 'patient1', birthDate: '1999-01-01', createdTime: '2021-10-19T16:27:59.504Z', - dataSources: [ - { providerName: 'dexcom', state: 'pending' }, - ], + // Pending lives on connectionRequests, not dataSources. + connectionRequests: { + dexcom: [{ + providerName: 'dexcom', + createdTime: '2021-10-19T16:30:00.000Z', + expirationTime: '2099-01-01T00:00:00.000Z', + }], + }, }, patient2: { id: 'patient2', @@ -348,9 +353,18 @@ describe('ClinicPatients', () => { email: 'patient7@test.ca', fullName: 'patient7', birthDate: '1999-01-01', + // PendingReconnect = non-connected dataSource + + // newer connectionRequest. dataSources: [ - { providerName: 'dexcom', state: 'pendingReconnect' }, + { providerName: 'dexcom', state: 'disconnected', modifiedTime: '2021-10-19T16:27:59.504Z' }, ], + connectionRequests: { + dexcom: [{ + providerName: 'dexcom', + createdTime: '2021-10-19T16:30:00.000Z', + expirationTime: '2099-01-01T00:00:00.000Z', + }], + }, }, }, }, From 5ffb9a9dd0a91d469cc0908c2f096fa16280086c Mon Sep 17 00:00:00 2001 From: clintonium-119 Date: Mon, 8 Jun 2026 12:56:55 -0400 Subject: [PATCH 3/8] Resolve pendingReconnect from any non-connected data source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the requestNewerThanDataSource recency gate in resolveConnectState. The backend contract (and clinic-worker's reconnect detection) treats a live, non-expired connection request alongside any non-connected data source as a reconnect, with no comparison of the request's createdTime against the data source's modifiedTime. Removing the gate aligns the frontend with that contract and makes the "invite already sent" status surface reliably — previously, a data source whose modifiedTime was bumped after the invite (e.g. a failed import) would render as a bare "Patient Disconnected", hiding the outstanding request from the clinician. Update the corresponding unit test and strip ticket references from the touched comments. --- app/components/datasources/DataConnections.js | 9 ++------- .../components/datasources/DataConnections.test.js | 10 +++++----- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/app/components/datasources/DataConnections.js b/app/components/datasources/DataConnections.js index 57a31f073b..dc37001e7c 100644 --- a/app/components/datasources/DataConnections.js +++ b/app/components/datasources/DataConnections.js @@ -179,12 +179,10 @@ export const getCurrentDataSourceForProvider = (patient, providerName) => { /** * Derive a single connectState identifier for a provider by joining - * patient.dataSources with patient.connectionRequests[providerName] per the - * BACK-4414 / WEB-4595 contract: + * patient.dataSources with patient.connectionRequests[providerName]: * * - pending = non-expired connectionRequest, no dataSource * - pendingReconnect = non-expired connectionRequest + non-connected dataSource - * whose modifiedTime predates the request's createdTime * - pendingExpired = expired connectionRequest + no connected dataSource * - connected = dataSource state === 'connected' * - disconnected = dataSource state === 'disconnected' @@ -201,13 +199,10 @@ export const resolveConnectState = (patient, providerName, now = moment.utc().to const connectionRequest = patient?.connectionRequests?.[providerName]?.[0]; const requestExpired = !!connectionRequest?.expirationTime && moment(connectionRequest.expirationTime).isBefore(now); - const requestNewerThanDataSource = !!connectionRequest?.createdTime - && (!dataSource?.modifiedTime - || moment(connectionRequest.createdTime).isAfter(dataSource.modifiedTime)); if (connectionRequest && !requestExpired) { if (!dataSource) return 'pending'; - if (dataSource.state !== 'connected' && requestNewerThanDataSource) return 'pendingReconnect'; + if (dataSource.state !== 'connected') return 'pendingReconnect'; } if (connectionRequest && requestExpired && (!dataSource || dataSource.state !== 'connected')) { diff --git a/test/unit/components/datasources/DataConnections.test.js b/test/unit/components/datasources/DataConnections.test.js index 188a648626..9f0af6adf3 100644 --- a/test/unit/components/datasources/DataConnections.test.js +++ b/test/unit/components/datasources/DataConnections.test.js @@ -197,12 +197,12 @@ describe('resolveConnectState', () => { expect(resolveConnectState(patient, 'dexcom', NOW)).to.equal('pendingReconnect'); }); - it('falls through to dataSource.state when connectionRequest.createdTime predates dataSource.modifiedTime', () => { + it('returns pendingReconnect even when dataSource.modifiedTime is newer than the request (recency is not compared)', () => { const patient = { dataSources: [{ providerName: 'dexcom', state: 'disconnected', modifiedTime: past(1) }], connectionRequests: { dexcom: [{ providerName: 'dexcom', createdTime: past(10), expirationTime: future(20) }] }, }; - expect(resolveConnectState(patient, 'dexcom', NOW)).to.equal('disconnected'); + expect(resolveConnectState(patient, 'dexcom', NOW)).to.equal('pendingReconnect'); }); it('returns connected when dataSource is connected, even with a recent connectionRequest', () => { @@ -328,9 +328,9 @@ describe('getCurrentDataSourceForProvider', () => { describe('getConnectStateUI', () => { // getConnectStateUI returns the full UI map keyed by connectState identifier; the actual // resolved state for a patient is the consumer's concern. These fixtures supply enough - // timing context for the UI map's messages to render. Post-BACK-4414, dataSource.state - // is one of ['connected', 'disconnected', 'error'] — 'disconnected' is the inert choice - // here since the fixtures aren't exercising state-driven branches. + // timing context for the UI map's messages to render. dataSource.state is one of + // ['connected', 'disconnected', 'error'] — 'disconnected' is the inert choice here + // since the fixtures aren't exercising state-driven branches. const clinicPatient = { id: 'patient123', dataSources: [ { providerName: 'provider123', state: 'disconnected' }], From ed2209216dc46befdb1651310a175c63aeffb9f6 Mon Sep 17 00:00:00 2001 From: clintonium-119 Date: Wed, 10 Jun 2026 10:13:17 -0400 Subject: [PATCH 4/8] v1.97.0-web-4595-pending-data-source-migration.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 97163e0043..b3cd992b84 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "node": "24.15.0" }, "packageManager": "yarn@3.6.4", - "version": "1.96.0", + "version": "1.97.0-web-4595-pending-data-source-migration.1", "private": true, "scripts": { "test": "TZ=UTC NODE_ENV=test yarn jest --verbose", From 9681e1ceac87cbfaf3275e9938a50ae70eb64f03 Mon Sep 17 00:00:00 2001 From: clintonium-119 Date: Wed, 10 Jun 2026 11:08:48 -0400 Subject: [PATCH 5/8] Select newest connection request by createdTime, not array order resolveConnectState and getConnectStateUI read connectionRequests[provider][0], which assumed the backend always returns the current request first. The backend confirms newer requests supersede older ones but treats newest-first ordering as incidental, so depending on [0] is fragile. Add getCurrentConnectionRequestForProvider, which selects the most recently created request via maxBy(createdTime), and route both read sites through it. This pins the resolved state to the meaningful request independent of array order. Add tests covering multi-request selection in either order and the case where the newest request is the expired one. --- app/components/datasources/DataConnections.js | 16 ++++++++++-- .../datasources/DataConnections.test.js | 25 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/app/components/datasources/DataConnections.js b/app/components/datasources/DataConnections.js index dc37001e7c..5d62b6fa70 100644 --- a/app/components/datasources/DataConnections.js +++ b/app/components/datasources/DataConnections.js @@ -14,6 +14,7 @@ import isFunction from 'lodash/isFunction'; import keys from 'lodash/keys'; import map from 'lodash/map'; import max from 'lodash/max'; +import maxBy from 'lodash/maxBy'; import noop from 'lodash/noop'; import orderBy from 'lodash/orderBy'; import reduce from 'lodash/reduce'; @@ -177,6 +178,17 @@ export const getCurrentDataSourceForProvider = (patient, providerName) => { return sortedDataSources[0]; }; +/** + * Return the meaningful connection request for a provider. A provider can carry more than one + * request in edge cases; newer requests supersede older ones, so only the most recently created + * request is meaningful. Selecting by createdTime keeps this correct independent of array order. + * + * @param {Object} patient + * @param {String} providerName + */ +export const getCurrentConnectionRequestForProvider = (patient, providerName) => + maxBy(patient?.connectionRequests?.[providerName], request => moment.utc(request.createdTime).valueOf()); + /** * Derive a single connectState identifier for a provider by joining * patient.dataSources with patient.connectionRequests[providerName]: @@ -196,7 +208,7 @@ export const getCurrentDataSourceForProvider = (patient, providerName) => { */ export const resolveConnectState = (patient, providerName, now = moment.utc().toISOString()) => { const dataSource = getCurrentDataSourceForProvider(patient, providerName); - const connectionRequest = patient?.connectionRequests?.[providerName]?.[0]; + const connectionRequest = getCurrentConnectionRequestForProvider(patient, providerName); const requestExpired = !!connectionRequest?.expirationTime && moment(connectionRequest.expirationTime).isBefore(now); @@ -226,7 +238,7 @@ export const getConnectStateUI = (patient, isLoggedInUser, providerName) => { dataSource?.modifiedTime, ]) : max([ dataSource?.modifiedTime, - patient?.connectionRequests?.[providerName]?.[0]?.createdTime + getCurrentConnectionRequestForProvider(patient, providerName)?.createdTime ]); let timeAgo; diff --git a/test/unit/components/datasources/DataConnections.test.js b/test/unit/components/datasources/DataConnections.test.js index 9f0af6adf3..d7b9d11449 100644 --- a/test/unit/components/datasources/DataConnections.test.js +++ b/test/unit/components/datasources/DataConnections.test.js @@ -237,6 +237,31 @@ describe('resolveConnectState', () => { }; expect(resolveConnectState(patient, 'dexcom', NOW)).to.equal('pending'); }); + + it('uses the newest request when multiple exist, regardless of array order', () => { + // An older expired request and a newer non-expired request. The newest supersedes, + // so the state is driven by the non-expired request — even when it is not first. + const olderExpired = { providerName: 'dexcom', createdTime: past(60), expirationTime: past(30) }; + const newerValid = { providerName: 'dexcom', createdTime: past(2), expirationTime: future(28) }; + + expect(resolveConnectState({ + connectionRequests: { dexcom: [olderExpired, newerValid] }, + }, 'dexcom', NOW)).to.equal('pending'); + + // Same set, reversed order — selection must not depend on index [0]. + expect(resolveConnectState({ + connectionRequests: { dexcom: [newerValid, olderExpired] }, + }, 'dexcom', NOW)).to.equal('pending'); + }); + + it('reports pendingExpired when the newest request is the expired one', () => { + const olderValidByExpiry = { providerName: 'dexcom', createdTime: past(60), expirationTime: future(5) }; + const newerExpired = { providerName: 'dexcom', createdTime: past(2), expirationTime: past(1) }; + + expect(resolveConnectState({ + connectionRequests: { dexcom: [olderValidByExpiry, newerExpired] }, + }, 'dexcom', NOW)).to.equal('pendingExpired'); + }); }); describe('getCurrentDataSourceForProvider', () => { From 3e20083b1295109415007feb2d8aec89568d3d3e Mon Sep 17 00:00:00 2001 From: henry-tp Date: Tue, 23 Jun 2026 15:35:42 -0700 Subject: [PATCH 6/8] WEB-4454 initial commit From 33fb983934454a87dd399590f21bed82a2a75c79 Mon Sep 17 00:00:00 2001 From: clintonium-119 Date: Thu, 25 Jun 2026 08:02:29 -0400 Subject: [PATCH 7/8] Small updates to address Copilot PR feedback --- app/components/datasources/DataConnections.js | 10 +++++++--- test/unit/pages/ClinicPatients.test.js | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/components/datasources/DataConnections.js b/app/components/datasources/DataConnections.js index 5d62b6fa70..2c40a5b118 100644 --- a/app/components/datasources/DataConnections.js +++ b/app/components/datasources/DataConnections.js @@ -187,7 +187,11 @@ export const getCurrentDataSourceForProvider = (patient, providerName) => { * @param {String} providerName */ export const getCurrentConnectionRequestForProvider = (patient, providerName) => - maxBy(patient?.connectionRequests?.[providerName], request => moment.utc(request.createdTime).valueOf()); + maxBy(patient?.connectionRequests?.[providerName], request => { + const createdTime = moment.utc(request.createdTime, moment.ISO_8601, true); + // Treat a missing/invalid createdTime as the oldest so it never wins selection. + return createdTime.isValid() ? createdTime.valueOf() : -Infinity; + }); /** * Derive a single connectState identifier for a provider by joining @@ -210,7 +214,7 @@ export const resolveConnectState = (patient, providerName, now = moment.utc().to const dataSource = getCurrentDataSourceForProvider(patient, providerName); const connectionRequest = getCurrentConnectionRequestForProvider(patient, providerName); const requestExpired = !!connectionRequest?.expirationTime - && moment(connectionRequest.expirationTime).isBefore(now); + && moment.utc(connectionRequest.expirationTime).isBefore(now); if (connectionRequest && !requestExpired) { if (!dataSource) return 'pending'; @@ -685,7 +689,7 @@ const connectionRequestShape = { DataConnections.propTypes = { ...BoxProps, patient: PropTypes.shape({ - dataSources: PropTypes.oneOf([PropTypes.shape(clinicPatientDataSourceShape), PropTypes.shape(userDataSourceShape)]), + dataSources: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.shape(clinicPatientDataSourceShape), PropTypes.shape(userDataSourceShape)])), connectionRequests: PropTypes.objectOf(PropTypes.arrayOf(PropTypes.shape(connectionRequestShape))), }), shownProviders: PropTypes.arrayOf(PropTypes.oneOf(availableProviders)), diff --git a/test/unit/pages/ClinicPatients.test.js b/test/unit/pages/ClinicPatients.test.js index 1614e3ea85..debc767988 100644 --- a/test/unit/pages/ClinicPatients.test.js +++ b/test/unit/pages/ClinicPatients.test.js @@ -354,7 +354,7 @@ describe('ClinicPatients', () => { fullName: 'patient7', birthDate: '1999-01-01', // PendingReconnect = non-connected dataSource + - // newer connectionRequest. + // non-expired connectionRequest. dataSources: [ { providerName: 'dexcom', state: 'disconnected', modifiedTime: '2021-10-19T16:27:59.504Z' }, ], From 8f12b3afadc9dc33d823a88a926aa5fd1f94ce69 Mon Sep 17 00:00:00 2001 From: clintonium-119 Date: Fri, 26 Jun 2026 11:50:45 -0400 Subject: [PATCH 8/8] Remove redundant test --- .../components/datasources/DataConnections.test.js | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/test/unit/components/datasources/DataConnections.test.js b/test/unit/components/datasources/DataConnections.test.js index d7b9d11449..d4c770f9e0 100644 --- a/test/unit/components/datasources/DataConnections.test.js +++ b/test/unit/components/datasources/DataConnections.test.js @@ -189,7 +189,7 @@ describe('resolveConnectState', () => { expect(resolveConnectState(patient, 'dexcom', NOW)).to.equal('pendingExpired'); }); - it('returns pendingReconnect when a non-expired connectionRequest exists alongside a non-connected dataSource whose modifiedTime predates the request', () => { + it('returns pendingReconnect when a non-expired connectionRequest exists alongside a non-connected dataSource', () => { const patient = { dataSources: [{ providerName: 'dexcom', state: 'disconnected', modifiedTime: past(10) }], connectionRequests: { dexcom: [{ providerName: 'dexcom', createdTime: past(2), expirationTime: future(28) }] }, @@ -197,14 +197,6 @@ describe('resolveConnectState', () => { expect(resolveConnectState(patient, 'dexcom', NOW)).to.equal('pendingReconnect'); }); - it('returns pendingReconnect even when dataSource.modifiedTime is newer than the request (recency is not compared)', () => { - const patient = { - dataSources: [{ providerName: 'dexcom', state: 'disconnected', modifiedTime: past(1) }], - connectionRequests: { dexcom: [{ providerName: 'dexcom', createdTime: past(10), expirationTime: future(20) }] }, - }; - expect(resolveConnectState(patient, 'dexcom', NOW)).to.equal('pendingReconnect'); - }); - it('returns connected when dataSource is connected, even with a recent connectionRequest', () => { const patient = { dataSources: [{ providerName: 'dexcom', state: 'connected', modifiedTime: past(1) }],