diff --git a/.gitignore b/.gitignore index f422994295..3eed11d703 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,5 @@ locales/*/*_old.json !.yarn/releases !.yarn/sdks !.yarn/versions + +.codegpt \ No newline at end of file diff --git a/app/components/clinic/PatientForm.js b/app/components/clinic/PatientForm.js index 4342731e81..2da1983c1d 100644 --- a/app/components/clinic/PatientForm.js +++ b/app/components/clinic/PatientForm.js @@ -20,15 +20,12 @@ import InputMask from 'react-input-mask'; import CloseRoundedIcon from '@material-ui/icons/CloseRounded'; import CheckCircleRoundedIcon from '@material-ui/icons/CheckCircleRounded'; import ErrorOutlineRoundedIcon from '@material-ui/icons/ErrorOutlineRounded'; -import { Box, Flex, Text, BoxProps } from 'theme-ui'; +import { Box, Text, BoxProps } from 'theme-ui'; import moment from 'moment'; import * as actions from '../../redux/actions'; -import Checkbox from '../../components/elements/Checkbox'; import TextInput from '../../components/elements/TextInput'; -import Button from '../../components/elements/Button'; import { TagList } from '../../components/elements/Tag'; -import ResendDexcomConnectRequestDialog from './ResendDexcomConnectRequestDialog'; import { useToasts } from '../../providers/ToastProvider'; import { getCommonFormikFieldProps } from '../../core/forms'; import { useInitialFocusedInput, useIsFirstRender, usePrevious } from '../../core/hooks'; @@ -36,31 +33,21 @@ import { dateRegex, patientSchema as validationSchema } from '../../core/clinicU import { accountInfoFromClinicPatient } from '../../core/personutils'; import { Body0 } from '../../components/elements/FontStyles'; import { borders, colors } from '../../themes/baseTheme'; -import Icon from '../elements/Icon'; -import DexcomLogoIcon from '../../core/icons/DexcomLogo.svg'; - -function getFormValues(source, clinicPatientTags, disableDexcom) { - const hasDexcomDataSource = !!find(source?.dataSources, { providerName: 'dexcom' }); - const connectDexcom = (hasDexcomDataSource || (!disableDexcom && source?.connectDexcom)) || false; - const addDexcomDataSource = connectDexcom && !hasDexcomDataSource; +export function getFormValues(source, clinicPatientTags) { return { birthDate: source?.birthDate || '', email: source?.email || '', fullName: source?.fullName || '', mrn: source?.mrn || '', tags: reject(source?.tags || [], tagId => !clinicPatientTags?.[tagId]), - connectDexcom, - dataSources: addDexcomDataSource ? [ - ...source?.dataSources || [], - { providerName: 'dexcom', state: 'pending' }, - ] : source?.dataSources || [], + dataSources: source?.dataSources || [], }; } -function emptyValuesFilter(value, key) { +export function emptyValuesFilter(value, key) { // We want to allow sending an empty `tags` array. Otherwise, strip empty fields from payload. - return !includes(['tags', 'connectDexcom'], key) && isEmpty(value); + return !includes(['tags'], key) && isEmpty(value); } export const PatientForm = (props) => { @@ -94,12 +81,8 @@ export const PatientForm = (props) => { const clinicPatientTags = useMemo(() => keyBy(clinic?.patientTags, 'id'), [clinic?.patientTags]); const dexcomDataSource = find(patient?.dataSources, { providerName: 'dexcom' }); const dexcomAuthInviteExpired = dexcomDataSource?.expirationTime < moment.utc().toISOString(); - const showConnectDexcom = action !== 'acceptInvite' && !!selectedClinicId && !dexcomDataSource; const showEmail = action !== 'acceptInvite'; - const [disableConnectDexcom, setDisableConnectDexcom] = useState(false); - const showDexcomConnectState = !!selectedClinicId && !!dexcomDataSource?.state; - const [showResendDexcomConnectRequest, setShowResendDexcomConnectRequest] = useState(false); - const { sendingPatientDexcomConnectRequest, fetchingPatientsForClinic } = useSelector((state) => state.blip.working); + const { fetchingPatientsForClinic } = useSelector((state) => state.blip.working); const [patientFetchOptions, setPatientFetchOptions] = useState({}); const loggedInUserId = useSelector((state) => state.blip.loggedInUserId); const previousFetchingPatientsForClinic = usePrevious(fetchingPatientsForClinic); @@ -156,6 +139,7 @@ export const PatientForm = (props) => { const formikContext = useFormik({ initialValues: getFormValues(patient, clinicPatientTags), + initialStatus: { showDataConnectionsModalNext: false }, onSubmit: (values, formikHelpers) => { const context = selectedClinicId ? 'clinic' : 'vca'; @@ -163,7 +147,7 @@ export const PatientForm = (props) => { edit: { clinic: { handler: 'updateClinicPatient', - args: () => [selectedClinicId, patient.id, omitBy({ ...patient, ...getFormValues(values, clinicPatientTags, disableConnectDexcom) }, emptyValuesFilter)], + args: () => [selectedClinicId, patient.id, omitBy({ ...patient, ...getFormValues(values, clinicPatientTags) }, emptyValuesFilter)], }, vca: { handler: 'updatePatient', @@ -173,7 +157,7 @@ export const PatientForm = (props) => { create: { clinic: { handler: 'createClinicCustodialAccount', - args: () => [selectedClinicId, omitBy(getFormValues(values, clinicPatientTags, disableConnectDexcom), emptyValuesFilter)], + args: () => [selectedClinicId, omitBy(getFormValues(values, clinicPatientTags), emptyValuesFilter)], }, vca: { handler: 'createVCACustodialAccount', @@ -184,7 +168,7 @@ export const PatientForm = (props) => { clinic: { handler: 'acceptPatientInvitation', args: () => [selectedClinicId, invite.key, invite.creatorId, omitBy( - pick(getFormValues(values, clinicPatientTags, disableConnectDexcom), ['mrn', 'birthDate', 'fullName', 'tags']), + pick(getFormValues(values, clinicPatientTags), ['mrn', 'birthDate', 'fullName', 'tags']), emptyValuesFilter )], }, @@ -195,57 +179,24 @@ export const PatientForm = (props) => { trackMetric(`${selectedClinicId ? 'Clinic' : 'Clinician'} - add patient email saved`); } - const emailUpdated = initialValues.email && values.email && (initialValues.email !== values.email); - - if (context === 'clinic' && values.connectDexcom && (!patient?.lastRequestedDexcomConnectTime || emailUpdated)) { - const reason = emailUpdated ? 'email updated' : 'initial connection request'; + const handlerArgs = actionMap[action][context].args(); - trackMetric('Clinic - Request dexcom connection for patient', { - clinicId: selectedClinicId, - reason, - action, - }); - - formikHelpers.setStatus('sendingDexcomConnectRequest'); + if (context === 'clinic' && action === 'create' && clinic?.country === 'US') { + formikHelpers.setStatus({ showDataConnectionsModalNext: true, newPatient: handlerArgs[1] }); } - dispatch(actions.async[actionMap[action][context].handler](api, ...actionMap[action][context].args())); + dispatch(actions.async[actionMap[action][context].handler](api, ...handlerArgs)); }, validationSchema: validationSchema({mrnSettings, existingMRNs}), }); const { - errors, setFieldValue, setValues, status, values, } = formikContext; - function handleAsyncResult(workingState, successMessage) { - const { inProgress, completed, notification } = workingState; - - if (!isFirstRender && !inProgress) { - if (completed) { - // Close the resend email modal and refetch patient details to update the connection status - setShowResendDexcomConnectRequest(false); - fetchPatientDetails(); - - setToast({ - message: successMessage, - variant: 'success', - }); - } - - if (completed === false) { - setToast({ - message: get(notification, 'message'), - variant: 'danger', - }); - } - } - } - // Fetchers useEffect(() => { if ( @@ -298,51 +249,15 @@ export const PatientForm = (props) => { onFormChange(formikContext); }, [values, clinicPatientTags, status]); - useEffect(() => { - if (includes(['create', 'edit'], action)) { - const hasValidEmail = !isEmpty(values.email) && !errors.email; - setDisableConnectDexcom(!hasValidEmail); - - if (values.connectDexcom && !hasValidEmail) { - setFieldValue('connectDexcom', false); - } - } - }, [values.email, errors.email, action]); - // Pull the patient on load to ensure the most recent dexcom connection state is made available useEffect(() => { if ((action === 'edit') && selectedClinicId && patient?.id) fetchPatientDetails(); }, []); - useEffect(() => { - handleAsyncResult(sendingPatientDexcomConnectRequest, t('Dexcom connection request to {{email}} has been resent.', { - email: patient?.email, - })); - }, [sendingPatientDexcomConnectRequest]); - - function handleResendDexcomConnectEmail() { - trackMetric('Clinic - Resend Dexcom connect email', { clinicId: selectedClinicId, dexcomConnectState, source: 'patientForm' }) - setShowResendDexcomConnectRequest(true); - } - - function handleResendDexcomConnectEmailConfirm() { - trackMetric('Clinic - Resend Dexcom connect email confirm', { clinicId: selectedClinicId, source: 'patientForm' }); - formikContext.setStatus('resendingDexcomConnectRequest'); - dispatch(actions.async.sendPatientDexcomConnectRequest(api, selectedClinicId, patient.id)); - } - function fetchPatientDetails() { dispatch(actions.async.fetchPatientFromClinic(api, selectedClinicId, patient.id)); } - function renderRegionalNote() { - return ( - - {t('For US Dexcom Users Only')} - - ); - } - const debounceSearch = useCallback( debounce((search) => { setPatientFetchOptions({ @@ -486,165 +401,6 @@ export const PatientForm = (props) => { )} )} - - {showConnectDexcom && ( - - - - {t('Connect with')} - - - - - )} - /> - - - {t('If this box is checked, patient will receive an email to authorize sharing Dexcom data with Tidepool.')} - - - {renderRegionalNote()} - - )} - - {showDexcomConnectState && ( - - - - - - {dexcomConnectStateUI[dexcomConnectState].label} - - - - - - {dexcomConnectState === 'pending' && ( - - {t('Patient has received an email to authorize Dexcom data sharing with Tidepool but they have not taken any action yet.')} - - - - )} - - {dexcomConnectState === 'pendingReconnect' && ( - - {t('Patient has received an email to reconnect their Dexcom data with Tidepool but they have not taken any action yet.')} - - - - )} - - {dexcomConnectState === 'pendingExpired' && ( - - {t('Patient invitation to authorize Dexcom data sharing with Tidepool has expired. Would you like to send a new connection request?')} - - - - )} - - {dexcomConnectState === 'disconnected' && ( - - {t('Patient has disconnected their Dexcom data sharing authorization with Tidepool. Would you like to send a new connection request?')} - - - - )} - - {dexcomConnectState === 'error' && ( - - {t('Patient\'s previous Dexcom authorization is no longer valid. Would you like to send a new connection request?')} - - - - )} - - {dexcomConnectStateUI[dexcomConnectState].showRegionalNote && renderRegionalNote()} - - setShowResendDexcomConnectRequest(false)} - onConfirm={handleResendDexcomConnectEmailConfirm} - open={showResendDexcomConnectRequest} - patient={patient} - t={t} - trackMetric={trackMetric} - /> - - )} - ); }; diff --git a/app/components/clinic/PatientLastReviewed.js b/app/components/clinic/PatientLastReviewed.js index efaf0f1f20..9da1d1c907 100644 --- a/app/components/clinic/PatientLastReviewed.js +++ b/app/components/clinic/PatientLastReviewed.js @@ -7,6 +7,7 @@ import moment from 'moment-timezone'; import CheckRoundedIcon from '@material-ui/icons/CheckRounded'; import { utils as vizUtils } from '@tidepool/viz'; import get from 'lodash/get'; +import upperFirst from 'lodash/upperFirst'; import HoverButton from '../elements/HoverButton'; import Icon from '../elements/Icon'; @@ -79,7 +80,7 @@ export const PatientLastReviewed = ({ api, patientId, recentlyReviewedThresholdD let clickHandler = handleReview; let buttonText = t('Mark Reviewed'); - let formattedLastReviewed = { text: '-' }; + let formattedLastReviewed = { daysText: '-' }; let lastReviewIsToday = false; let reviewIsRecent = false; let canReview = true; @@ -132,7 +133,7 @@ export const PatientLastReviewed = ({ api, patientId, recentlyReviewedThresholdD }} > {reviewIsRecent && } - {formattedLastReviewed.text} + {upperFirst(formattedLastReviewed.daysText)} diff --git a/app/components/clinic/ResendDexcomConnectRequestDialog.js b/app/components/clinic/ResendDataSourceConnectRequestDialog.js similarity index 57% rename from app/components/clinic/ResendDexcomConnectRequestDialog.js rename to app/components/clinic/ResendDataSourceConnectRequestDialog.js index 9be5e0dc82..16347e7cea 100644 --- a/app/components/clinic/ResendDexcomConnectRequestDialog.js +++ b/app/components/clinic/ResendDataSourceConnectRequestDialog.js @@ -5,25 +5,27 @@ import { useSelector } from 'react-redux'; import { Text } from 'theme-ui'; import sundial from 'sundial'; -import Button from '../../components/elements/Button'; -import { Body1, MediumTitle } from '../../components/elements/FontStyles'; +import Button from '../elements/Button'; +import { Body1, MediumTitle } from '../elements/FontStyles'; +import { providers } from '../datasources/DataConnections'; import { Dialog, DialogTitle, DialogContent, DialogActions, -} from '../../components/elements/Dialog'; +} from '../elements/Dialog'; -export const ResendDexcomConnectRequestDialog = (props) => { - const { t, onClose, onConfirm, open, patient } = props; +export const ResendDataSourceConnectRequestDialog = (props) => { + const { t, onClose, onConfirm, open, patient, providerName } = props; const timePrefs = useSelector((state) => state.blip.timePrefs); - const { sendingPatientDexcomConnectRequest } = useSelector((state) => state.blip.working); + const { sendingPatientDataProviderConnectRequest } = useSelector((state) => state.blip.working); + const providerDisplayName = providers[providerName]?.displayName; - const formattedLastRequestedDexcomConnectDate = - patient?.lastRequestedDexcomConnectTime && + const formattedLastRequestedDataSourceConnectDate = + patient?.connectionRequests?.[providerName]?.[0]?.createdTime && sundial.formatInTimezone( - patient?.lastRequestedDexcomConnectTime, + patient.connectionRequests[providerName][0].createdTime, timePrefs?.timezoneName || new Intl.DateTimeFormat().resolvedOptions().timeZone, 'MM/DD/YYYY [at] h:mm a' @@ -31,7 +33,7 @@ export const ResendDexcomConnectRequestDialog = (props) => { return ( { - {formattedLastRequestedDexcomConnectDate && ( + {formattedLastRequestedDataSourceConnectDate && ( - You requested {{patient: patient?.fullName || patient?.email}} to connect to Dexcom on {{requestDate: formattedLastRequestedDexcomConnectDate}}. + You requested {{patient: patient?.fullName || patient?.email}} to connect to {{provider: providerDisplayName}} on {{requestDate: formattedLastRequestedDataSourceConnectDate}}. )} @@ -58,9 +60,9 @@ export const ResendDexcomConnectRequestDialog = (props) => { {t('Cancel')} } + + + ); +}; + +DataConnection.propTypes = { + ...FlexProps, + buttonDisabled: PropTypes.bool, + buttonIcon: PropTypes.elementType, + buttonHandler: PropTypes.func, + buttonProcessing: PropTypes.bool, + buttonStyle: PropTypes.oneOf(['solid', 'text', 'staticText']), + buttonText: PropTypes.string, + icon: PropTypes.elementType, + iconLabel: PropTypes.string, + label: PropTypes.string.isRequired, + logoImage: PropTypes.elementType, + logoImageLabel: PropTypes.string.isRequired, + messageColor: PropTypes.string.isRequired, + messageText: PropTypes.string.isRequired, + stateColor: PropTypes.string.isRequired, + stateText: PropTypes.string.isRequired, +}; + +DataConnection.defaultProps = {}; + +export default DataConnection; diff --git a/app/components/datasources/DataConnections.js b/app/components/datasources/DataConnections.js new file mode 100644 index 0000000000..7615921deb --- /dev/null +++ b/app/components/datasources/DataConnections.js @@ -0,0 +1,586 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import ErrorRoundedIcon from '@material-ui/icons/ErrorRounded'; +import CheckCircleRoundedIcon from '@material-ui/icons/CheckCircleRounded'; +import CheckRoundedIcon from '@material-ui/icons/CheckRounded'; +import moment from 'moment-timezone'; +import find from 'lodash/find'; +import get from 'lodash/get'; +import includes from 'lodash/includes'; +import keys from 'lodash/keys'; +import map from 'lodash/map'; +import max from 'lodash/max'; +import noop from 'lodash/noop'; +import reduce from 'lodash/reduce'; +import { utils as vizUtils } from '@tidepool/viz'; + +import * as actions from '../../redux/actions'; +import { useToasts } from '../../providers/ToastProvider'; +import api from '../../core/api'; +import { useIsFirstRender, usePrevious } from '../../core/hooks'; +import i18next from '../../core/language'; +import DataConnection from './DataConnection'; +import PatientEmailModal from './PatientEmailModal'; +import ResendDataSourceConnectRequestDialog from '../clinic/ResendDataSourceConnectRequestDialog'; +import { Box, BoxProps } from 'theme-ui'; +import dexcomLogo from '../../core/icons/dexcom_logo.svg'; +import libreLogo from '../../core/icons/libre_logo.svg'; +import twiistLogo from '../../core/icons/twiist_logo.svg'; +import { colors } from '../../themes/baseTheme'; +import { isFunction } from 'lodash'; + +const { formatTimeAgo } = vizUtils.datetime; +const t = i18next.t.bind(i18next); + +export const activeProviders = [ + 'dexcom', + 'abbott', +]; + +export const providers = { + dexcom: { + id: 'oauth/dexcom', + displayName: 'Dexcom', + restrictedTokenCreate: { + paths: [ + '/v1/oauth/dexcom', + ], + }, + dataSourceFilter: { + providerType: 'oauth', + providerName: 'dexcom', + }, + logoImage: dexcomLogo, + }, + abbott: { + id: 'oauth/abbott', + displayName: 'Freestyle Libre', + restrictedTokenCreate: { + paths: [ + '/v1/oauth/abbott', + ], + }, + dataSourceFilter: { + providerType: 'oauth', + providerName: 'abbott', + }, + logoImage: libreLogo, + }, + twiist: { + id: 'oauth/twiist', + displayName: 'Twiist', + restrictedTokenCreate: { + paths: [ + '/v1/oauth/twiist', + ], + }, + dataSourceFilter: { + providerType: 'oauth', + providerName: 'twiist', + }, + logoImage: twiistLogo, + }, +}; + +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. + 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: { + buttonText: t('Connect'), + buttonStyle: 'solid', + action: actions.async.connectDataSource, + args: [api, id, restrictedTokenCreate, dataSourceFilter], + }, + disconnect: { + buttonText: t('Disconnect'), + buttonStyle: 'text', + action: actions.async.disconnectDataSource, + args: [api, id, dataSourceFilter], + }, + inviteSent: { + buttonDisabled: true, + buttonIcon: CheckRoundedIcon, + buttonText: t('Invite Sent'), + buttonStyle: 'staticText', + action: actions.async.connectDataSource, + args: [api, id, restrictedTokenCreate, dataSourceFilter], + }, + reconnect: { + buttonText: t('Reconnect'), + buttonStyle: 'solid', + action: actions.async.connectDataSource, + args: [api, id, restrictedTokenCreate, dataSourceFilter], + }, + sendInvite: { + buttonText: t('Email Invite'), + buttonStyle: 'solid', + action: actions.async.sendPatientDataProviderConnectRequest, + args: [api, selectedClinicId, patient?.id, providerName], + emailRequired, + patientUpdates, + }, + resendInvite: { + buttonText: t('Resend Invite'), + buttonStyle: 'solid', + action: actions.async.sendPatientDataProviderConnectRequest, + args: [api, selectedClinicId, patient?.id, providerName], + emailRequired, + patientUpdates, + }, + } +}; + +export const getConnectStateUI = (patient, isLoggedInUser, providerName) => { + const dataSource = find(patient?.dataSources, {providerName}); + + const mostRecentConnectionUpdateTime = isLoggedInUser + ? max([ + dataSource?.createdTime, + dataSource?.latestDataTime || dataSource?.lastImportTime, + dataSource?.modifiedTime, + ]) : max([ + dataSource?.modifiedTime, + patient?.connectionRequests?.[providerName]?.[0]?.createdTime + ]); + + let timeAgo; + let inviteJustSent; + + if (mostRecentConnectionUpdateTime) { + const { daysAgo, daysText, hoursAgo, hoursText, minutesAgo, minutesText } = formatTimeAgo(mostRecentConnectionUpdateTime); + timeAgo = daysText; + if (daysAgo < 1) timeAgo = hoursAgo < 1 ? minutesText : hoursText; + if (!isLoggedInUser && minutesAgo < 1) inviteJustSent = true; + } + + let patientConnectedMessage; + let patientConnectedIcon; + let patientConnectedText = t('Connected'); + + if (!dataSource?.lastImportTime) { + patientConnectedMessage = t('This can take a few minutes'); + patientConnectedText = t('Connecting'); + } else if (!dataSource?.latestDataTime) { + patientConnectedMessage = t('No data found as of {{timeAgo}}', { timeAgo }); + } else { + patientConnectedMessage = t('Last data {{timeAgo}}', { timeAgo }); + patientConnectedIcon = CheckCircleRoundedIcon; + } + + return { + noPendingConnections: { + color: colors.grays[5], + handler: isLoggedInUser ? 'connect' : 'sendInvite', + icon: null, + message: null, + text: isLoggedInUser ? null : t('No Pending Connections'), + }, + inviteJustSent: { + color: colors.grays[5], + handler: 'inviteSent', + icon: null, + message: null, + text: t('Connection Pending'), + }, + pending: { + color: colors.grays[5], + handler: isLoggedInUser ? 'connect' : 'resendInvite', + icon: null, + message: t('Invite sent {{timeAgo}}', { timeAgo }), + text: t('Connection Pending'), + inviteJustSent, + }, + pendingReconnect: { + color: colors.grays[5], + handler: isLoggedInUser ? 'connect' : 'resendInvite', + icon: null, + message: t('Invite sent {{timeAgo}}', { timeAgo }), + text: t('Invite Sent'), + inviteJustSent, + }, + pendingExpired: { + color: colors.feedback.warning, + handler: isLoggedInUser ? 'connect' : 'resendInvite', + icon: ErrorRoundedIcon, + message: t('Sent over one month ago'), + text: t('Invite Expired'), + }, + connected: { + color: colors.text.primary, + handler: isLoggedInUser ? 'disconnect' : null, + message: isLoggedInUser ? patientConnectedMessage : null, + icon: isLoggedInUser ? patientConnectedIcon : CheckCircleRoundedIcon, + text: isLoggedInUser ? patientConnectedText : t('Connected'), + }, + disconnected: { + color: colors.feedback.warning, + handler: isLoggedInUser ? 'connect' : 'resendInvite', + icon: isLoggedInUser ? null : ErrorRoundedIcon, + message: isLoggedInUser ? null : t('Last update {{timeAgo}}', { timeAgo }), + text: isLoggedInUser ? null : t('Patient Disconnected'), + }, + error: { + color: colors.feedback.warning, + handler: isLoggedInUser ? 'reconnect' : 'resendInvite', + icon: ErrorRoundedIcon, + message: isLoggedInUser + ? t('Last update {{timeAgo}}. Please reconnect your account to keep syncing data.', { timeAgo }) + : t('Last update {{timeAgo}}', { timeAgo }), + text: t('Error Connecting'), + }, + unknown: { + color: colors.feedback.warning, + handler: null, + icon: ErrorRoundedIcon, + text: t('Unknown Status'), + }, + } +}; + +export const getDataConnectionProps = (patient, isLoggedInUser, selectedClinicId, setActiveHandler) => reduce(activeProviders, (result, providerName) => { + result[providerName] = {}; + + let connectState; + + const connectStateUI = getConnectStateUI(patient, isLoggedInUser, providerName); + const dataSource = find(patient?.dataSources, { providerName: 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'; + } + + const { color, icon, message, text, handler } = connectStateUI[connectState]; + + const { + action, + args, + buttonDisabled, + buttonIcon, + 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].buttonText = buttonText; + result[providerName].buttonStyle = buttonStyle; + } + + result[providerName].icon = icon; + result[providerName].iconLabel = `connection status: ${connectState}`; + result[providerName].label = `${providerName} data connection state`; + result[providerName].messageColor = colors.grays[5]; + result[providerName].messageText = message; + result[providerName].stateColor = color; + result[providerName].stateText = text; + result[providerName].providerName = providerName; + result[providerName].logoImage = providers[providerName]?.logoImage; + result[providerName].logoImageLabel = `${providerName} logo`; + + return result; +}, {}); + +export const DataConnections = (props) => { + const { + patient, + trackMetric, + ...themeProps + } = props; + + const dispatch = useDispatch(); + const isFirstRender = useIsFirstRender(); + const { set: setToast } = useToasts(); + const selectedClinicId = useSelector((state) => state.blip.selectedClinicId); + const isLoggedInUser = useSelector((state) => state.blip.loggedInUserId === patient?.id); + const [showResendDataSourceConnectRequest, setShowResendDataSourceConnectRequest] = useState(false); + 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 { + sendingPatientDataProviderConnectRequest, + updatingClinicPatient, + } = useSelector((state) => state.blip.working); + + const previousSendingPatientDataProviderConnectRequest = usePrevious(sendingPatientDataProviderConnectRequest); + const previousUpdatingClinicPatient = usePrevious(updatingClinicPatient); + + const fetchPatientDetails = useCallback(() => { + dispatch(actions.async.fetchPatientFromClinic(api, selectedClinicId, patient?.id)); + }, [ + dispatch, + selectedClinicId, + patient?.id, + ]); + + // Pull the patient on load to ensure the most recent dexcom connection state is made available + useEffect(() => { + if (selectedClinicId && patient?.id) fetchPatientDetails(); + }, []); + + const handleAsyncResult = useCallback((workingState, successMessage, onComplete) => { + const { inProgress, completed, notification, prevInProgress } = workingState; + + if (!isFirstRender && !inProgress && prevInProgress !== false) { + if (completed) { + if (isFunction(onComplete)) onComplete(); + + if (successMessage) setToast({ + message: successMessage, + variant: 'success', + }); + } + + if (completed === false) { + setToast({ + message: get(notification, 'message'), + variant: 'danger', + }); + + setShowPatientEmailModal(false); + setProcessingEmailUpdate(false); + setPatientUpdates({}); + setActiveHandler(null); + } + } + }, [ + isFirstRender, + setToast, + ]); + + const handleAddPatientEmailOpen = useCallback(() => { + trackMetric('Data Connections - add patient email', { selectedClinicId }); + setShowPatientEmailModal(true); + }, [ + selectedClinicId, + trackMetric, + ]); + + const handleAddPatientEmailClose = () => { + setShowPatientEmailModal(false); + setActiveHandler(null); + }; + + const handleAddPatientEmailFormChange = (formikContext) => { + setPatientEmailFormContext({ ...formikContext }); + }; + + const handleAddPatientEmailConfirm = () => { + trackMetric('Data Connections - add patient email confirmed', { selectedClinicId }); + patientEmailFormContext?.handleSubmit(); + setProcessingEmailUpdate(true); + }; + + const handleUpdatePatientComplete = useCallback(() => { + 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)); + } + } + }, [ + activeHandler, + dispatch, + fetchPatientDetails, + ]); + + const handleResendDataSourceConnectEmailOpen = useCallback(() => { + trackMetric('Clinic - Resend DataSource connect email', { + clinicId: selectedClinicId, + providerName: activeHandler?.providerName, + dataSourceConnectState: activeHandler?.connectState, + source: 'patientForm', + }); + + setShowResendDataSourceConnectRequest(true); + }, [ + activeHandler?.connectState, + activeHandler?.providerName, + selectedClinicId, + trackMetric, + ]); + + const handleResendDataSourceConnectEmailClose = () => { + setShowResendDataSourceConnectRequest(false); + setActiveHandler(null); + }; + + const handleResendDataSourceConnectEmailConfirm = () => { + trackMetric('Clinic - Resend DataSource connect email confirm', { clinicId: selectedClinicId, source: 'patientForm' }); + if (activeHandler?.action) dispatch(activeHandler.action(...activeHandler.args)); + }; + + const handleActiveHandlerComplete = useCallback(() => { + setShowPatientEmailModal(false); + setShowResendDataSourceConnectRequest(false); + setActiveHandler(null); + fetchPatientDetails(); + }, [fetchPatientDetails]); + + useEffect(() => { + if(activeHandler?.action && !activeHandler?.inProgress) { + 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 || {}); + 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)); + } + } + }, [ + activeHandler, + dispatch, + handleAddPatientEmailOpen, + handleResendDataSourceConnectEmailOpen, + patient, + selectedClinicId, + ]); + + useEffect(() => { + handleAsyncResult({ ...updatingClinicPatient, prevInProgress: previousUpdatingClinicPatient?.inProgress}, t('You have successfully updated a patient.'), handleUpdatePatientComplete); + }, [ + handleAsyncResult, + handleUpdatePatientComplete, + updatingClinicPatient, + previousUpdatingClinicPatient?.inProgress, + setToast, + ]); + + useEffect(() => { + handleAsyncResult({ ...sendingPatientDataProviderConnectRequest, prevInProgress: previousSendingPatientDataProviderConnectRequest?.inProgress }, t('{{ providerDisplayName }} connection request to {{email}} has been sent.', { + email: patient?.email, + providerDisplayName: providers[activeHandler?.providerName]?.displayName, + }), handleActiveHandlerComplete); + }, [ + sendingPatientDataProviderConnectRequest, + previousSendingPatientDataProviderConnectRequest?.inProgress, + handleAsyncResult, + handleActiveHandlerComplete, + activeHandler?.providerName, + patient?.email + ]); + + return ( + <> + + {map(activeProviders, (provider, i) => ( + + ))} + + + {showPatientEmailModal && } + + + + ); +}; + +const clinicPatientDataSourceShape = { + expirationTime: PropTypes.string, + modifiedTime: PropTypes.string, + providerName: PropTypes.string.isRequired, + state: PropTypes.oneOf(['connected', 'disconnected', 'error', 'pending', 'pendingReconnect']).isRequired, +}; + +const userDataSourceShape = { + createdTime: PropTypes.string, + lastImportTime: PropTypes.string, + latestDataTime: PropTypes.string, + modifiedTime: PropTypes.string, + providerName: PropTypes.string.isRequired, + state: PropTypes.oneOf(['connected', 'disconnected', 'error', 'pending', 'pendingReconnect']).isRequired, +}; + +DataConnections.propTypes = { + ...BoxProps, + patient: PropTypes.oneOf([PropTypes.shape(clinicPatientDataSourceShape), PropTypes.shape(userDataSourceShape)]), + trackMetric: PropTypes.func.isRequired, +}; + +DataConnections.defaultProps = { + trackMetric: noop, +}; + +export default DataConnections; diff --git a/app/components/datasources/DataConnectionsModal.js b/app/components/datasources/DataConnectionsModal.js new file mode 100644 index 0000000000..a20b0b76ce --- /dev/null +++ b/app/components/datasources/DataConnectionsModal.js @@ -0,0 +1,225 @@ +import React, { useCallback, useState, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import PropTypes from 'prop-types'; +import get from 'lodash/get'; +import noop from 'lodash/noop'; +import { Box, Divider, Link } from 'theme-ui'; +import EditIcon from '@material-ui/icons/EditRounded'; + +import * as actions from '../../redux/actions'; +import Button from './../../components/elements/Button'; +import DataConnections from './DataConnections'; +import PatientDetails from './PatientDetails'; +import { clinicPatientFromAccountInfo } from '../../core/personutils'; +import { useToasts } from './../../providers/ToastProvider'; + +import { + Dialog, + DialogActions, + DialogContent, + DialogTitle, +} from '../../components/elements/Dialog'; + +import { Body1, MediumTitle, Subheading } from '../../components/elements/FontStyles'; +import api from '../../core/api'; +import { useIsFirstRender, usePrevious } from '../../core/hooks'; +import i18next from '../../core/language'; +import { URL_TIDEPOOL_EXTERNAL_DATA_CONNECTIONS, URL_UPLOADER_DOWNLOAD_PAGE } from '../../core/constants'; +import PatientEmailModal from './PatientEmailModal'; + +const t = i18next.t.bind(i18next); + +export const DataConnectionsModal = (props) => { + const { + open, + onClose, + onBack, + patient, + trackMetric, + } = props; + + const isFirstRender = useIsFirstRender(); + const { set: setToast } = useToasts(); + const selectedClinicId = useSelector((state) => state.blip.selectedClinicId); + const { updatingClinicPatient } = useSelector((state) => state.blip.working); + const previousUpdatingClinicPatient = usePrevious(updatingClinicPatient); + const patientData = (patient?.profile) ? clinicPatientFromAccountInfo(patient) : patient; + const [showPatientEmailModal, setShowPatientEmailModal] = useState(false); + const [processingEmailUpdate, setProcessingEmailUpdate] = useState(false); + const [patientEmailFormContext, setPatientEmailFormContext] = useState(); + const dispatch = useDispatch(); + + const fetchPatientDetails = useCallback(() => { + dispatch(actions.async.fetchPatientFromClinic(api, selectedClinicId, patient.id)); + }, [dispatch, patient.id, selectedClinicId]) + + // Pull the patient on load to ensure the most recent dexcom connection state is made available + useEffect(() => { + if (selectedClinicId && patient?.id) fetchPatientDetails(); + }, []); + + const handleEditPatientEmailOpen = () => { + trackMetric('Data Connections - edit patient email', { selectedClinicId }); + setShowPatientEmailModal(true); + }; + + const handleEditPatientEmailClose = () => { + setShowPatientEmailModal(false); + }; + + function handleEditPatientEmailFormChange(formikContext) { + setPatientEmailFormContext({ ...formikContext }); + } + + const handleEditPatientEmailConfirm = () => { + trackMetric('Data Connections - edit patient email confirmed', { selectedClinicId }); + patientEmailFormContext?.handleSubmit(); + setProcessingEmailUpdate(true); + }; + + const handleEditPatientEmailComplete = useCallback(() => { + fetchPatientDetails(); + setShowPatientEmailModal(false); + }, [fetchPatientDetails, setShowPatientEmailModal]); + + useEffect(() => { + const { inProgress, completed, notification } = updatingClinicPatient; + const prevInProgress = previousUpdatingClinicPatient?.inProgress; + + if (!isFirstRender && !inProgress && prevInProgress !== false) { + if (completed) { + handleEditPatientEmailComplete(); + + setToast({ + message: t('You have successfully updated the patient email address.'), + variant: 'success', + }); + } + + if (completed === false) { + setToast({ + message: get(notification, 'message'), + variant: 'danger', + }); + } + + setProcessingEmailUpdate(false); + } + }, [ + handleEditPatientEmailComplete, + isFirstRender, + updatingClinicPatient, + previousUpdatingClinicPatient?.inProgress, + setToast, + ]); + + return ( + <> + + + {t('Bring Data into Tidepool')} + + + + + {t('Connect a Device Account')} + + + + {t('Invite patients to authorize syncing from these accounts. Only available in the US at this time.')}  + {t('Learn more.')} + + + {patientData?.email && patient?.permissions?.custodian && ( + + {t('Email:')} + + + )} + + + + + + + {t('Have other devices with data to view? Tidepool supports over 85 devices. To add data from a device directly, search for this patient in')}  + {t('Tidepool Uploader')},  + {t('select the devices, and upload.')}  + + + {showPatientEmailModal && } + + + + + + + + ); +}; + +DataConnectionsModal.propTypes = { + onBack: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + open: PropTypes.bool, + patient: PropTypes.object.isRequired, + trackMetric: PropTypes.func.isRequired, +}; + +DataConnectionsModal.defaultProps = { + onClose: noop, + trackMetric: noop, +}; + + +export default DataConnectionsModal; diff --git a/app/components/datasources/PatientDetails.js b/app/components/datasources/PatientDetails.js new file mode 100644 index 0000000000..03b3785372 --- /dev/null +++ b/app/components/datasources/PatientDetails.js @@ -0,0 +1,52 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Flex, Text, FlexProps } from 'theme-ui'; + +import { Body0, Body1 } from '../elements/FontStyles'; + +import i18next from '../../core/language'; + +const t = i18next.t.bind(i18next); + +export const PatientDetails = (props) => { + const { + patient, + ...themeProps + } = props; + + return ( + + + {patient.fullName} + + + + {t('DOB: {{birthDate}}', patient)} + {t('MRN: {{mrn}}', { mrn: patient.mrn || '-' })} + + + ); +}; + +PatientDetails.propTypes = { + ...FlexProps, + patient: PropTypes.object.isRequired, +}; + +export default PatientDetails; diff --git a/app/components/datasources/PatientEmailModal.js b/app/components/datasources/PatientEmailModal.js new file mode 100644 index 0000000000..434bec5c0b --- /dev/null +++ b/app/components/datasources/PatientEmailModal.js @@ -0,0 +1,169 @@ +import React, { useEffect, useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import keyBy from 'lodash/keyBy'; +import omitBy from 'lodash/omitBy'; +import { useFormik } from 'formik'; +import { Box, BoxProps } from 'theme-ui'; +import compact from 'lodash/compact'; +import map from 'lodash/map'; +import noop from 'lodash/noop'; +import reject from 'lodash/reject'; + +import * as actions from '../../redux/actions'; +import Banner from './../../components/elements/Banner'; +import Button from './../../components/elements/Button'; +import { getCommonFormikFieldProps } from '../../core/forms'; +import { useInitialFocusedInput } from '../../core/hooks'; +import { patientSchema as validationSchema } from '../../core/clinicUtils'; + +import { + Dialog, + DialogActions, + DialogContent, + DialogTitle, +} from '../../components/elements/Dialog'; + +import { MediumTitle } from '../../components/elements/FontStyles'; +import TextInput from '../../components/elements/TextInput'; +import { emptyValuesFilter, getFormValues } from '../../components/clinic/PatientForm'; +import api from '../../core/api'; +import i18next from '../../core/language'; + +const t = i18next.t.bind(i18next); + +export const PatientEmailModal = (props) => { + const { + open, + onClose, + onFormChange, + onSubmit, + processing, + patient, + trackMetric, + } = props; + + const dispatch = useDispatch(); + const selectedClinicId = useSelector((state) => state.blip.selectedClinicId); + const clinic = useSelector(state => state.blip.clinics?.[selectedClinicId]); + const mrnSettings = clinic?.mrnSettings ?? {}; + + const existingMRNs = useMemo( + () => compact(map(reject(clinic?.patients, { id: patient?.id }), 'mrn')), + [clinic?.patients, patient?.id] + ); + + const clinicPatientTags = useMemo(() => keyBy(clinic?.patientTags, 'id'), [clinic?.patientTags]); + const initialFocusedInputRef = useInitialFocusedInput(); + const action = useMemo(() => patient?.email ? 'edit' : 'addAndSendInvite', [patient?.email]); + + const formikContext = useFormik({ + initialValues: getFormValues(patient, clinicPatientTags), + onSubmit: (values) => { + trackMetric(`Data Connections - patient email ${action === 'edit' ? 'updated' : 'added'}`); + dispatch(actions.async.updateClinicPatient(api, selectedClinicId, patient.id, omitBy({ ...patient, ...getFormValues(values, clinicPatientTags) }, emptyValuesFilter))); + }, + validationSchema: validationSchema({mrnSettings, existingMRNs}), + }); + + const { + values, + isValid, + } = formikContext; + + const UI = { + addAndSendInvite: { + title: t('Add a Patient Email'), + submitText: t('Send Invite'), + banner: { + message: t('We will send an invitation to this email address. Please note that the recipient will be given the opportunity to claim this Tidepool account.'), + title: t('Please set the email address for this patient account.'), + variant: 'info', + }, + }, + edit: { + title: t('Edit Patient Email'), + submitText: t('Save'), + banner: { + message: t('The recipient of this email will have the opportunity to claim this Tidepool account.'), + title: t('Changing this email will update the email associated with the account.'), + variant: 'warning', + }, + }, + }; + + useEffect(() => { + onFormChange(formikContext); + }, [values]); + + return ( + + + {UI[action].title} + + + + + + + + + + + + + + + + + + + ); +}; + +PatientEmailModal.propTypes = { + onClose: PropTypes.func.isRequired, + onFormChange: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + open: PropTypes.bool, + patient: PropTypes.object.isRequired, + processing: PropTypes.bool, + trackMetric: PropTypes.func.isRequired, +}; + +PatientEmailModal.defaultProps = { + onClose: noop, + onComplete: noop, + trackMetric: noop, +}; + + +export default PatientEmailModal; diff --git a/app/components/dexcombanner/dexcombanner.js b/app/components/dexcombanner/dexcombanner.js index 650dea5226..7441b4cc18 100644 --- a/app/components/dexcombanner/dexcombanner.js +++ b/app/components/dexcombanner/dexcombanner.js @@ -26,7 +26,7 @@ import { URL_DEXCOM_CONNECT_INFO } from '../../core/constants'; import { useIsFirstRender } from '../../core/hooks'; import { async, sync } from '../../redux/actions'; import { useToasts } from '../../providers/ToastProvider'; -import ResendDexcomConnectRequestDialog from '../clinic/ResendDexcomConnectRequestDialog'; +import ResendDataSourceConnectRequestDialog from '../clinic/ResendDataSourceConnectRequestDialog'; export const DexcomBanner = withTranslation()((props) => { const { @@ -47,7 +47,7 @@ export const DexcomBanner = withTranslation()((props) => { const isFirstRender = useIsFirstRender(); const { set: setToast } = useToasts(); const [showResendDexcomConnectRequest, setShowResendDexcomConnectRequest] = useState(false); - const { sendingPatientDexcomConnectRequest } = useSelector((state) => state.blip.working); + const { sendingPatientDataProviderConnectRequest } = useSelector((state) => state.blip.working); const redirectToProfile = (source = 'banner') => { push(`/patients/${patient.userid}/profile?dexcomConnect=${source}`); @@ -77,10 +77,10 @@ export const DexcomBanner = withTranslation()((props) => { } useEffect(() => { - handleAsyncResult(sendingPatientDexcomConnectRequest, t('Dexcom connection request to {{email}} has been resent.', { + handleAsyncResult(sendingPatientDataProviderConnectRequest, t('Dexcom connection request to {{email}} has been resent.', { email: patient?.email, })); - }, [sendingPatientDexcomConnectRequest]); + }, [sendingPatientDataProviderConnectRequest]); const handleSendReconnectionEmail = () => { trackMetric('Clinic - Resend Dexcom connect email', { clinicId: selectedClinicId, dexcomConnectState: dataSourceState, source: 'banner' }) @@ -89,7 +89,7 @@ export const DexcomBanner = withTranslation()((props) => { const handleSendReconnectionEmailConfirm = () => { trackMetric('Clinic - Resend Dexcom connect email confirm', { clinicId: selectedClinicId, source: 'banner' }); - dispatch(async.sendPatientDexcomConnectRequest(api, selectedClinicId, clinicPatient.id)); + dispatch(async.sendPatientDataProviderConnectRequest(api, selectedClinicId, clinicPatient.id, 'dexcom')); }; const handleDismiss = () => { @@ -177,12 +177,13 @@ export const DexcomBanner = withTranslation()((props) => { - setShowResendDexcomConnectRequest(false)} onConfirm={handleSendReconnectionEmailConfirm} open={showResendDexcomConnectRequest} patient={clinicPatient} + providerName="dexcom" t={t} trackMetric={trackMetric} /> diff --git a/app/components/elements/Banner.js b/app/components/elements/Banner.js index 93afd95e36..b33431f080 100644 --- a/app/components/elements/Banner.js +++ b/app/components/elements/Banner.js @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Flex, Text, FlexProps } from 'theme-ui'; +import { Flex, Text, Box, FlexProps } from 'theme-ui'; import CloseRoundedIcon from '@material-ui/icons/CloseRounded'; import ErrorRoundedIcon from '@material-ui/icons/ErrorRounded'; import InfoRoundedIcon from '@material-ui/icons/InfoRounded'; @@ -15,12 +15,13 @@ import Button from './Button'; export function Banner(props) { const { actionText, + dismissable, label, - variant, message, - dismissable, onAction, onDismiss, + title, + variant, ...themeProps } = props; @@ -42,7 +43,10 @@ export function Banner(props) { > - {message} + + {title} + {message} + {!!actionText && ( )} @@ -62,20 +66,21 @@ export function Banner(props) { Banner.propTypes = { ...FlexProps, - message: PropTypes.string.isRequired, - variant: PropTypes.oneOf(['default', 'warning', 'danger', 'success']), - label: PropTypes.string.isRequired, + actionText: PropTypes.string, dismissable: PropTypes.bool, + label: PropTypes.string.isRequired, + message: PropTypes.string.isRequired, onAction: PropTypes.func, onDismiss: PropTypes.func, - actionText: PropTypes.string, + title: PropTypes.string, + variant: PropTypes.oneOf(['default', 'warning', 'danger', 'success']), }; Banner.defaultProps = { - variant: 'info', dismissable: true, onDismiss: noop, onAction: noop, + variant: 'info', }; export default Banner; diff --git a/app/components/elements/Dialog.js b/app/components/elements/Dialog.js index ce0d90fca0..e50cb46285 100644 --- a/app/components/elements/Dialog.js +++ b/app/components/elements/Dialog.js @@ -105,7 +105,7 @@ const StyledDialogContent = styled(Box)` export function DialogContent({ sx = {}, ...props }) { return ; @@ -133,7 +133,8 @@ const StyledDialogActions = styled(Flex)` export function DialogActions(props) { return ; } diff --git a/app/components/elements/Icon.js b/app/components/elements/Icon.js index 778d1b8a82..ee7b25325e 100644 --- a/app/components/elements/Icon.js +++ b/app/components/elements/Icon.js @@ -29,6 +29,7 @@ const StyledIcon = styled(Box)` export function Icon(props) { const { active, + children, cursor = 'pointer', icon: IconElement, iconSrc, @@ -57,6 +58,7 @@ export function Icon(props) { {...buttonProps} > {iconSrc ? : } + {children} ); } diff --git a/app/core/api.js b/app/core/api.js index d99f7522e4..4af3f49709 100644 --- a/app/core/api.js +++ b/app/core/api.js @@ -1041,8 +1041,8 @@ api.clinics.sendPatientUploadReminder = function(clinicId, patientId, cb) { return tidepool.sendPatientUploadReminder(clinicId, patientId, cb); }; -api.clinics.sendPatientDexcomConnectRequest = function(clinicId, patientId, cb) { - return tidepool.sendPatientDexcomConnectRequest(clinicId, patientId, cb); +api.clinics.sendPatientDataProviderConnectRequest = function(clinicId, patientId, providerName, cb) { + return tidepool.sendPatientDataProviderConnectRequest(clinicId, patientId, providerName, cb); }; api.clinics.createClinicPatientTag = function(clinicId, patientTag, cb) { diff --git a/app/core/constants.js b/app/core/constants.js index d437047e5b..a87b3e5618 100644 --- a/app/core/constants.js +++ b/app/core/constants.js @@ -28,6 +28,7 @@ export const URL_UPLOADER_DOWNLOAD_PAGE = 'https://tidepool.org/products/tidepoo export const URL_SHARE_DATA_INFO = 'https://support.tidepool.org/hc/en-us/articles/360029684951-Share-your-Data'; export const URL_TIDEPOOL_PLUS_PLANS = 'https://tidepool.org/providers/tidepoolplus/plans'; export const URL_TIDEPOOL_PLUS_CONTACT_SALES = 'https://app.cronofy.com/add_to_calendar/scheduling/-hq0nDA6'; +export const URL_TIDEPOOL_EXTERNAL_DATA_CONNECTIONS = 'https://support.tidepool.org/hc/en-us/articles/360029369552-Connecting-your-Dexcom-account-to-Tidepool'; export const TIDEPOOL_DATA_DONATION_ACCOUNT_EMAIL = 'bigdata@tidepool.org'; diff --git a/app/core/icons/DataInIcon.svg b/app/core/icons/DataInIcon.svg new file mode 100644 index 0000000000..0e4efb980a --- /dev/null +++ b/app/core/icons/DataInIcon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/core/icons/dexcom_logo.svg b/app/core/icons/dexcom_logo.svg new file mode 100644 index 0000000000..cabb342ac5 --- /dev/null +++ b/app/core/icons/dexcom_logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/core/icons/libre_logo.svg b/app/core/icons/libre_logo.svg new file mode 100644 index 0000000000..b4aa8d7ab4 --- /dev/null +++ b/app/core/icons/libre_logo.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/app/core/icons/twiist_logo.svg b/app/core/icons/twiist_logo.svg new file mode 100644 index 0000000000..3aafa11fa3 --- /dev/null +++ b/app/core/icons/twiist_logo.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/app/pages/clinicworkspace/ClinicPatients.js b/app/pages/clinicworkspace/ClinicPatients.js index 53370a922b..9c20af432f 100644 --- a/app/pages/clinicworkspace/ClinicPatients.js +++ b/app/pages/clinicworkspace/ClinicPatients.js @@ -22,11 +22,11 @@ import omit from 'lodash/omit'; import orderBy from 'lodash/orderBy'; import pick from 'lodash/pick'; import reject from 'lodash/reject'; +import upperFirst from 'lodash/upperFirst'; import values from 'lodash/values'; import without from 'lodash/without'; import { Box, Flex, Link, Text } from 'theme-ui'; import AddIcon from '@material-ui/icons/Add'; -import CheckRoundedIcon from '@material-ui/icons/CheckRounded'; import CloseRoundedIcon from '@material-ui/icons/CloseRounded'; import DeleteIcon from '@material-ui/icons/DeleteRounded'; import DoubleArrowIcon from '@material-ui/icons/DoubleArrow'; @@ -77,6 +77,7 @@ import Popover from '../../components/elements/Popover'; import RadioGroup from '../../components/elements/RadioGroup'; import Checkbox from '../../components/elements/Checkbox'; import FilterIcon from '../../core/icons/FilterIcon.svg'; +import DataInIcon from '../../core/icons/DataInIcon.svg'; import SendEmailIcon from '../../core/icons/SendEmailIcon.svg'; import TabularReportIcon from '../../core/icons/TabularReportIcon.svg'; import utils from '../../core/utils'; @@ -106,10 +107,11 @@ import { import { MGDL_UNITS, MMOLL_UNITS, URL_TIDEPOOL_PLUS_PLANS } from '../../core/constants'; import { borders, radii, colors, space, fontWeights } from '../../themes/baseTheme'; import PopoverElement from '../../components/elements/PopoverElement'; +import DataConnectionsModal from '../../components/datasources/DataConnectionsModal'; const { Loader } = vizComponents; const { reshapeBgClassesToBgBounds, generateBgRangeLabels, formatBgValue } = vizUtils.bg; -const { getLocalizedCeiling, getTimezoneFromTimePrefs, formatTimeAgo } = vizUtils.datetime; +const { getLocalizedCeiling, formatTimeAgo } = vizUtils.datetime; const StyledScrollToTop = styled(ScrollToTop)` background-color: ${colors.purpleMedium}; @@ -143,6 +145,12 @@ const editPatient = (patient, setSelectedPatient, selectedClinicId, trackMetric, setShowEditPatientDialog(true); }; +const editPatientDataConnections = (patient, setSelectedPatient, selectedClinicId, trackMetric, setShowDataConnectionsModal, source) => { + trackMetric('Clinic - Edit patient data connections', { clinicId: selectedClinicId, source }); + setSelectedPatient(patient); + setShowDataConnectionsModal(true); +}; + const MoreMenu = ({ patient, isClinicAdmin, @@ -151,6 +159,7 @@ const MoreMenu = ({ t, trackMetric, setSelectedPatient, + setShowDataConnectionsModal, setShowEditPatientDialog, prefixPopHealthMetric, setShowSendUploadReminderDialog, @@ -160,6 +169,10 @@ const MoreMenu = ({ editPatient(patient, setSelectedPatient, selectedClinicId, trackMetric, setShowEditPatientDialog, 'action menu'); }, [patient, setSelectedPatient, selectedClinicId, trackMetric, setShowEditPatientDialog]); + const handleEditPatientDataConnections = useCallback(() => { + editPatientDataConnections(patient, setSelectedPatient, selectedClinicId, trackMetric, setShowDataConnectionsModal, 'action menu'); + }, [patient, setSelectedPatient, selectedClinicId, trackMetric, setShowDataConnectionsModal]); + const handleSendUploadReminder = useCallback( (patient) => { trackMetric(prefixPopHealthMetric('Send upload reminder'), { @@ -199,6 +212,17 @@ const MoreMenu = ({ handleEditPatient(patient); }, text: t('Edit Patient Information'), + }, { + iconSrc: DataInIcon, + iconLabel: t('Bring Data into Tidepool'), + iconPosition: 'left', + id: `edit-data-connections-${patient.id}`, + variant: 'actionListItem', + onClick: (_popupState) => { + _popupState.close(); + handleEditPatientDataConnections(patient); + }, + text: t('Bring Data into Tidepool'), }); if (showSummaryData && patient.email && !patient.permissions?.custodian) { @@ -451,6 +475,7 @@ export const ClinicPatients = (props) => { const [showRpmReportConfigDialog, setShowRpmReportConfigDialog] = useState(false); const [showRpmReportLimitDialog, setShowRpmReportLimitDialog] = useState(false); const [showTideDashboardConfigDialog, setShowTideDashboardConfigDialog] = useState(false); + const [showDataConnectionsModal, setShowDataConnectionsModal] = useState(false); const [showEditPatientDialog, setShowEditPatientDialog] = useState(false); const [showClinicPatientTagsDialog, setShowClinicPatientTagsDialog] = useState(false); const [showTimeInRangeDialog, setShowTimeInRangeDialog] = useState(false); @@ -633,6 +658,33 @@ export const ClinicPatients = (props) => { const prefixPopHealthMetric = useCallback(metric => `Clinic - Population Health - ${metric}`, []); + const handleCloseOverlays = useCallback(() => { + const resetList = showAddPatientDialog || showEditPatientDialog; + setShowAddPatientDialog(false); + setShowDeleteDialog(false); + setShowDataConnectionsModal(false); + setShowEditPatientDialog(false); + setShowClinicPatientTagsDialog(false); + setShowTimeInRangeDialog(false); + setShowSendUploadReminderDialog(false); + setShowTideDashboardConfigDialog(false); + setShowRpmReportConfigDialog(false); + setShowRpmReportLimitDialog(false); + + if (resetList) { + setPatientFetchOptions({ ...patientFetchOptions }); + } + + setTimeout(() => { + setPatientFormContext(null); + setSelectedPatient(null); + }); + }, [ + showAddPatientDialog, + showEditPatientDialog, + patientFetchOptions, + ]); + const handleCloseClinicPatientTagUpdateDialog = useCallback(metric => { if (metric) trackMetric(prefixPopHealthMetric(metric, { clinicId: selectedClinicId })); setShowDeleteClinicPatientTagDialog(false); @@ -667,38 +719,51 @@ export const ClinicPatients = (props) => { } }, [isFirstRender, setToast]); - useEffect(() => { - handleAsyncResult({ ...updatingClinicPatient, prevInProgress: previousUpdatingClinicPatient?.inProgress }, t('You have successfully updated a patient.'), () => { - handleCloseOverlays(); + const handlePatientCreatedOrEdited = useCallback(() => { + if (patientFormContext?.status?.showDataConnectionsModalNext) { + let currentPatient = selectedPatient; - if (patientFormContext?.status === 'sendingDexcomConnectRequest') { - dispatch(actions.async.sendPatientDexcomConnectRequest(api, selectedClinicId, updatingClinicPatient.patientId)); - } - }); + if (patientFormContext?.status?.newPatient && creatingClinicCustodialAccount?.patientId) currentPatient = { + ...patientFormContext.status.newPatient, + id: creatingClinicCustodialAccount.patientId, + }; + + setShowAddPatientDialog(false); + setShowEditPatientDialog(false); + editPatientDataConnections(currentPatient, setSelectedPatient, selectedClinicId, trackMetric, setShowDataConnectionsModal, 'Patients list - patient modal'); + } else { + handleCloseOverlays(); + } }, [ - api, - dispatch, + handleCloseOverlays, + patientFormContext?.status, + creatingClinicCustodialAccount, selectedClinicId, + selectedPatient, + trackMetric, + ]); + + useEffect(() => { + // Only process detected updates if patient edit form is showing. Other child components, such as + // the PatientEmailModal, may also update the patient, and handle the results + if (showEditPatientDialog) { + handleAsyncResult({ ...updatingClinicPatient, prevInProgress: previousUpdatingClinicPatient?.inProgress }, t('You have successfully updated a patient.'), handlePatientCreatedOrEdited); + } + }, [ handleAsyncResult, + handlePatientCreatedOrEdited, t, updatingClinicPatient, patientFormContext?.status, previousUpdatingClinicPatient?.inProgress, + showEditPatientDialog, ]); useEffect(() => { - handleAsyncResult({ ...creatingClinicCustodialAccount, prevInProgress: previousCreatingClinicCustodialAccount?.inProgress }, t('You have successfully added a new patient.'), () => { - handleCloseOverlays(); - - if (patientFormContext?.status === 'sendingDexcomConnectRequest') { - dispatch(actions.async.sendPatientDexcomConnectRequest(api, selectedClinicId, creatingClinicCustodialAccount.patientId)); - } - }); + handleAsyncResult({ ...creatingClinicCustodialAccount, prevInProgress: previousCreatingClinicCustodialAccount?.inProgress }, t('You have successfully added a new patient.'), handlePatientCreatedOrEdited); }, [ - api, - dispatch, - selectedClinicId, handleAsyncResult, + handlePatientCreatedOrEdited, t, creatingClinicCustodialAccount, patientFormContext?.status, @@ -1013,6 +1078,12 @@ export const ClinicPatients = (props) => { patientFormContext?.handleSubmit(); }, [patientFormContext, selectedClinicId, trackMetric, selectedPatient?.tags, prefixPopHealthMetric]); + const handleEditPatientAndAddDataSourcesConfirm = useCallback(() => { + trackMetric('Clinic - Edit patient next', { clinicId: selectedClinicId, source: 'Patients list' }); + patientFormContext?.setStatus({ showDataConnectionsModalNext: true }); + handleEditPatientConfirm(); + }, [patientFormContext, selectedClinicId, trackMetric, handleEditPatientConfirm]); + function handleConfigureTideDashboard() { if (validateTideConfig(tideDashboardConfig[localConfigKey], patientTags)) { trackMetric('Clinic - Navigate to Tide Dashboard', { clinicId: selectedClinicId, source: 'Patients list' }); @@ -2196,17 +2267,27 @@ export const ClinicPatients = (props) => { + + ); }, [ + handleCloseOverlays, showRpmReportLimitDialog, t, ]); - function handleCloseOverlays() { - const resetList = showAddPatientDialog || showEditPatientDialog; - setShowDeleteDialog(false); - setShowAddPatientDialog(false); - setShowEditPatientDialog(false); - setShowClinicPatientTagsDialog(false); - setShowTimeInRangeDialog(false); - setShowSendUploadReminderDialog(false); - setShowTideDashboardConfigDialog(false); - setShowRpmReportConfigDialog(false); - setShowRpmReportLimitDialog(false); - - if (resetList) { - setPatientFetchOptions({ ...patientFetchOptions }); - } - - setTimeout(() => { - setSelectedPatient(null); - }); - } + const renderDataConnectionsModal = useCallback(() => { + return ( + { + setShowDataConnectionsModal(false) + setShowEditPatientDialog(true) + } : undefined} + /> + ); + }, [ + handleCloseOverlays, + patientFormContext?.status, + selectedPatient, + ]); const renderPatient = useCallback(patient => ( @@ -2794,7 +2876,7 @@ export const ClinicPatients = (props) => { whiteSpace: 'nowrap', }} > - {formattedLastDataDateCGM.text} + {upperFirst(formattedLastDataDateCGM.daysText)} )} @@ -2809,7 +2891,7 @@ export const ClinicPatients = (props) => { whiteSpace: 'nowrap', }} > - {formattedLastDataDateBGM.text} + {upperFirst(formattedLastDataDateBGM.daysText)} )} @@ -2978,6 +3060,7 @@ export const ClinicPatients = (props) => { t={t} trackMetric={trackMetric} setSelectedPatient={setSelectedPatient} + setShowDataConnectionsModal={setShowDataConnectionsModal} setShowEditPatientDialog={setShowEditPatientDialog} prefixPopHealthMetric={prefixPopHealthMetric} setShowSendUploadReminderDialog={setShowSendUploadReminderDialog} @@ -3250,6 +3333,8 @@ export const ClinicPatients = (props) => { {showTimeInRangeDialog && renderTimeInRangeDialog()} {showSendUploadReminderDialog && renderSendUploadReminderDialog()} {showClinicPatientTagsDialog && renderClinicPatientTagsDialog()} + {showDataConnectionsModal && renderDataConnectionsModal()} + { + let start = startDate; + let end = endDate; + + if (isNumber(startDate) && isNumber(endDate)) { + start = startDate - getOffset(startDate, timezone) * MS_IN_MIN; + end = endDate - getOffset(endDate, timezone) * MS_IN_MIN; + } + + return formatDateRange(start, end, dateParseFormat, monthFormat); +}; + +const getReportDaysText = (newestDatum, oldestDatum, bgDaysWorn, timezone) => { + const reportDaysText = bgDaysWorn === 1 + ? moment.utc(newestDatum?.time - getOffset(newestDatum?.time, timezone) * MS_IN_MIN).format('MMMM D, YYYY') + : getDateRange(oldestDatum?.time, newestDatum?.time, undefined, '', 'MMMM', timezone); + + return reportDaysText; +}; + +export default getReportDaysText; \ No newline at end of file diff --git a/app/pages/dashboard/PatientDrawer/CGMStatistics.js b/app/pages/dashboard/PatientDrawer/CGMStatistics/index.js similarity index 58% rename from app/pages/dashboard/PatientDrawer/CGMStatistics.js rename to app/pages/dashboard/PatientDrawer/CGMStatistics/index.js index 1e8ebfa8c0..e86c8e526e 100644 --- a/app/pages/dashboard/PatientDrawer/CGMStatistics.js +++ b/app/pages/dashboard/PatientDrawer/CGMStatistics/index.js @@ -1,25 +1,12 @@ import React from 'react'; -import colorPalette from '../../../themes/colorPalette'; +import colorPalette from '../../../../themes/colorPalette'; import { useTranslation } from 'react-i18next'; -import { useSelector } from 'react-redux'; import { Flex, Box, Text } from 'theme-ui'; -import moment from 'moment'; import { utils as vizUtils } from '@tidepool/viz'; -import utils from '../../../core/utils'; -import { MGDL_UNITS } from '../../../core/constants'; - -const formatDateRange = (startEndpoint, endEndpoint, timezoneName) => { - const startDate = moment.utc(startEndpoint).tz(timezoneName); - const endDate = moment.utc(endEndpoint).tz(timezoneName); - const startYear = startDate.year(); - const endYear = endDate.year(); - - if (startYear !== endYear) { - return `${startDate.format('MMMM D, YYYY')} - ${endDate.format('MMMM D, YYYY')}`; - } - - return `${startDate.format('MMMM D')} - ${endDate.format('MMMM D')}, ${endDate.format('YYYY')}`; -} +const { formatDatum, bankersRound } = vizUtils.stat; +const { getTimezoneFromTimePrefs } = vizUtils.datetime; +import { MGDL_UNITS } from '../../../../core/constants'; +import getReportDaysText from './getReportDaysText'; const TableRow = ({ label, sublabel, value, units, id }) => { return ( @@ -46,8 +33,8 @@ const TableRow = ({ label, sublabel, value, units, id }) => { {units && {units}} - ) -} + ); +}; const CGMStatistics = ({ agpCGM }) => { const { t } = useTranslation(); @@ -56,32 +43,31 @@ const CGMStatistics = ({ agpCGM }) => { const { timePrefs, - bgPrefs: { bgUnits }, + bgPrefs, data: { current: { - endpoints: { days: endpointDays }, stats: { - bgExtents: { newestDatum, oldestDatum }, + bgExtents: { newestDatum, oldestDatum, bgDaysWorn }, sensorUsage: { sensorUsageAGP }, averageGlucose: { averageGlucose }, glucoseManagementIndicator: { glucoseManagementIndicatorAGP }, - coefficientOfVariation: { coefficientOfVariation } + coefficientOfVariation: { coefficientOfVariation }, }, - } - } + }, + }, } = agpCGM; - const timezoneName = vizUtils.datetime.getTimezoneFromTimePrefs(timePrefs); + const { bgUnits } = bgPrefs; - const avgGlucosePrecision = bgUnits === MGDL_UNITS ? 0 : 1; - const avgGlucoseTarget = bgUnits === MGDL_UNITS ? '154' : '8.6'; + const timezone = getTimezoneFromTimePrefs(timePrefs); - const dateRange = formatDateRange(oldestDatum.time, newestDatum.time, timezoneName); - const daySpan = endpointDays; - const cgmActive = utils.roundToPrecision(sensorUsageAGP, 1); - const avgGlucose = utils.roundToPrecision(averageGlucose, avgGlucosePrecision); - const gmi = utils.roundToPrecision(glucoseManagementIndicatorAGP, 1); - const cov = utils.roundToPrecision(coefficientOfVariation, 1); + const avgGlucoseTarget = bgUnits === MGDL_UNITS ? '154' : '8.6'; + + const dateRange = getReportDaysText(newestDatum, oldestDatum, bgDaysWorn, timezone); + const cgmActive = bankersRound(sensorUsageAGP, 1); + const avgGlucose = formatDatum({ value: averageGlucose }, 'bgValue', { bgPrefs, useAGPFormat: true }); + const gmi = formatDatum({ value: glucoseManagementIndicatorAGP }, 'gmi', { bgPrefs, useAGPFormat: true }); + const cov = formatDatum({ value: coefficientOfVariation }, 'cv', { bgPrefs, useAGPFormat: true }); return ( @@ -89,7 +75,7 @@ const CGMStatistics = ({ agpCGM }) => { { id="agp-table-avg-glucose" label={t('Average Glucose')} sublabel={t('(Goal <{{avgGlucoseTarget}} {{bgUnits}})', { avgGlucoseTarget, bgUnits })} - value={`${avgGlucose}`} + value={avgGlucose?.value} units={` ${bgUnits}`} /> - ) -} + ); +}; export default CGMStatistics; \ No newline at end of file diff --git a/app/pages/dashboard/PatientDrawer/Content.js b/app/pages/dashboard/PatientDrawer/Content.js index b117e44067..686eea9f62 100644 --- a/app/pages/dashboard/PatientDrawer/Content.js +++ b/app/pages/dashboard/PatientDrawer/Content.js @@ -61,7 +61,8 @@ const Content = ({ api, patientId, agpPeriodInDays }) => { if (status === STATUS.NO_PATIENT_DATA) return ; if (status === STATUS.INSUFFICIENT_DATA) return ; - if (status !== STATUS.SVGS_GENERATED) return + + if (status !== STATUS.SVGS_GENERATED) return ; const percentInRanges = svgDataURLS?.agpCGM?.percentInRanges; const ambulatoryGlucoseProfile = svgDataURLS?.agpCGM?.ambulatoryGlucoseProfile; diff --git a/app/pages/dashboard/PatientDrawer/MenuBar/CGMClipboardButton.js b/app/pages/dashboard/PatientDrawer/MenuBar/CGMClipboardButton.js index 549b454655..9087ea7863 100644 --- a/app/pages/dashboard/PatientDrawer/MenuBar/CGMClipboardButton.js +++ b/app/pages/dashboard/PatientDrawer/MenuBar/CGMClipboardButton.js @@ -1,119 +1,50 @@ import React, { useEffect, useState, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import Button from '../../../../components/elements/Button'; -import utils from '../../../../core/utils'; -import { MGDL_UNITS } from '../../../../core/constants'; +import { MS_IN_HOUR } from '../../../../core/constants'; +import { Box } from 'theme-ui'; import { utils as vizUtils } from '@tidepool/viz'; -const { TextUtil } = vizUtils.text; -import { Box } from 'theme-ui' -import moment from 'moment'; - -const formatDateRange = (startEndpoint, endEndpoint, timezoneName) => { - const startDate = moment.utc(startEndpoint).tz(timezoneName); - const endDate = moment.utc(endEndpoint).tz(timezoneName); - const startYear = startDate.year(); - const endYear = endDate.year(); - - if (startYear !== endYear) { - return `${startDate.format('MMMM D, YYYY')} - ${endDate.format('MMMM D, YYYY')}`; - } - - return `${startDate.format('MMMM D')} - ${endDate.format('MMMM D')}, ${endDate.format('YYYY')}`; -} - -const getCGMClipboardText = (patient, agpCGM, t) => { - if (!agpCGM || !patient) return ''; - - const { fullName, birthDate } = patient; - - const { - timePrefs, - bgPrefs: { bgUnits }, - data: { - current: { - stats: { - bgExtents: { newestDatum, oldestDatum }, - averageGlucose: { averageGlucose }, - timeInRange: { counts }, - }, - } - } - } = agpCGM; - - const timezoneName = vizUtils.datetime.getTimezoneFromTimePrefs(timePrefs); - - const currentDate = moment().format('MMMM Do, YYYY'); - - // TODO: Add test for no data scenario - const dateRange = formatDateRange(oldestDatum?.time, newestDatum?.time, timezoneName); - - const targetRange = bgUnits === MGDL_UNITS ? '70-180' : '3.9-10.0'; - const lowRange = bgUnits === MGDL_UNITS ? '54-70' : '3.0-3.9'; - const veryLowRange = bgUnits === MGDL_UNITS ? '<54' : '<3.0'; - - const countsInTarget = utils.roundToPrecision((counts.target / counts.total) * 100, 0); - const countsInLow = utils.roundToPrecision((counts.low * 100 ) / counts.total, 0); - const countsInVeryLow = utils.roundToPrecision((counts.veryLow * 100 ) / counts.total, 0); - - const avgGlucose = utils.roundToPrecision(averageGlucose, bgUnits === MGDL_UNITS ? 0 : 1); - - const textUtil = new TextUtil(); - let clipboardText = ''; - - clipboardText += textUtil.buildTextLine(fullName); - clipboardText += textUtil.buildTextLine(t('Date of birth: {{birthDate}}', { birthDate })); - clipboardText += textUtil.buildTextLine(t('Exported from Tidepool TIDE: {{currentDate}}', { currentDate })); - clipboardText += textUtil.buildTextLine(''); - clipboardText += textUtil.buildTextLine(t('Reporting Period: {{dateRange}}', { dateRange })); - clipboardText += textUtil.buildTextLine(''); - clipboardText += textUtil.buildTextLine(t('Avg. Daily Time In Range ({{bgUnits}})', { bgUnits })); - clipboardText += textUtil.buildTextLine(t('{{targetRange}} {{countsInTarget}}%', { targetRange, countsInTarget })); - clipboardText += textUtil.buildTextLine(t('{{lowRange}} {{countsInLow}}%', { lowRange, countsInLow })); - clipboardText += textUtil.buildTextLine(t('{{veryLowRange}} {{countsInVeryLow}}%', { veryLowRange, countsInVeryLow })); - clipboardText += textUtil.buildTextLine(''); - clipboardText += textUtil.buildTextLine(t('Avg. Glucose (CGM): {{avgGlucose}} {{bgUnits}}', { avgGlucose, bgUnits })); - - return clipboardText; -} +const { agpCGMText } = vizUtils.text; const STATE = { DEFAULT: 'DEFAULT', CLICKED: 'CLICKED', -} +}; -const CGMClipboardButton = ({ patient, agpCGM }) => { +const CGMClipboardButton = ({ patient, data }) => { const { t } = useTranslation(); const [buttonState, setButtonState] = useState(STATE.DEFAULT); + const clipboardText = useMemo(() => agpCGMText(patient, data), [patient, data]); useEffect(() => { let buttonTextEffect = setTimeout(() => { - setButtonState(STATE.DEFAULT) + setButtonState(STATE.DEFAULT); }, 1000); return () => { clearTimeout(buttonTextEffect); - } - }, [buttonState]) + }; + }, [buttonState]); - const sensorUsage = agpCGM?.data?.current?.stats?.sensorUsage?.sensorUsage || 0; + const { count, sampleFrequency } = data?.data?.current?.stats?.sensorUsage || {}; - const isDisabled = !agpCGM || sensorUsage < 86400000; // minimum 24 hours + const hoursOfCGMData = (count * sampleFrequency) / MS_IN_HOUR; - const clipboardText = useMemo(() => getCGMClipboardText(patient, agpCGM, t), [patient, agpCGM, t]); + const isDataInsufficient = !hoursOfCGMData || hoursOfCGMData < 24; const handleCopy = () => { navigator?.clipboard?.writeText(clipboardText); setButtonState(STATE.CLICKED); - } + }; return ( - - ) -} + ); +}; export default CGMClipboardButton; \ No newline at end of file diff --git a/app/pages/dashboard/PatientDrawer/MenuBar/MenuBar.js b/app/pages/dashboard/PatientDrawer/MenuBar/MenuBar.js index e001a1b62b..05a5883836 100644 --- a/app/pages/dashboard/PatientDrawer/MenuBar/MenuBar.js +++ b/app/pages/dashboard/PatientDrawer/MenuBar/MenuBar.js @@ -19,7 +19,7 @@ const MenuBar = ({ patientId, api, trackMetric, onClose }) => { const selectedClinicId = useSelector(state => state.blip.selectedClinicId); const patient = useSelector(state => state.blip.clinics[state.blip.selectedClinicId]?.patients?.[patientId]); - const agpCGM = useSelector(state => state.blip.pdf?.data?.agpCGM); // IMPORTANT: Data taken from Redux PDF slice + const pdf = useSelector(state => state.blip.pdf); // IMPORTANT: Data taken from Redux PDF slice useEffect(() => { // DOB field in Patient object may not be populated in TIDE Dashboard, so we need to refetch @@ -35,10 +35,10 @@ const MenuBar = ({ patientId, api, trackMetric, onClose }) => { const handleReviewSuccess = () => { setTimeout(() => { onClose(); - }, 500) - } + }, 500); + }; - const { fullName, birthDate } = patient || {}; + const { fullName, birthDate } = patient || {}; return ( @@ -47,7 +47,7 @@ const MenuBar = ({ patientId, api, trackMetric, onClose }) => { {fullName} { birthDate && - + {t('DOB: {{birthDate}}', { birthDate })} } @@ -60,7 +60,7 @@ const MenuBar = ({ patientId, api, trackMetric, onClose }) => { - + diff --git a/app/pages/dashboard/PatientDrawer/useAgpCGM/getOpts.js b/app/pages/dashboard/PatientDrawer/useAgpCGM/getOpts.js index 13b543dca3..34867d76d6 100644 --- a/app/pages/dashboard/PatientDrawer/useAgpCGM/getOpts.js +++ b/app/pages/dashboard/PatientDrawer/useAgpCGM/getOpts.js @@ -2,6 +2,7 @@ import moment from 'moment-timezone'; import _ from 'lodash'; import get from 'lodash/get'; import { utils as vizUtils } from '@tidepool/viz'; +import utils from '../../../../core/utils'; const getTimezoneFromTimePrefs = vizUtils.datetime.getTimezoneFromTimePrefs; @@ -9,37 +10,28 @@ const getOpts = ( data, // data from redux (state.blip.data) agpPeriodInDays, ) => { - const getMostRecentDatumTimeByChartType = (data, chartType) => { - let latestDatums; + const getMostRecentDatumTimeByChartType = (data, _chartType) => { const getLatestDatums = types => _.pick(_.get(data, 'metaData.latestDatumByType'), types); - switch (chartType) { // cases for 'trends', 'bgLog', 'daily', and 'basics' omitted - case 'agpBGM': - latestDatums = getLatestDatums([ - 'smbg', - ]); - break; - - case 'agpCGM': - latestDatums = getLatestDatums([ - 'cbg', - ]); - break; - - default: - latestDatums = []; - break; - } - + let latestDatums = getLatestDatums(['cbg']) || []; + return _.max(_.map(latestDatums, d => (d.normalEnd || d.normalTime))); - } + }; const mostRecentDatumDates = { - agpBGM: getMostRecentDatumTimeByChartType(data, 'agpBGM'), agpCGM: getMostRecentDatumTimeByChartType(data, 'agpCGM'), }; - const timezoneName = getTimezoneFromTimePrefs(data?.timePrefs); + const timePrefs = (() => { + const latestTimeZone = data?.metaData?.latestTimeZone; + const queryParams = {}; + + const localTimePrefs = utils.getTimePrefsForDataProcessing(latestTimeZone, queryParams); + + return localTimePrefs; + })(); + + const timezoneName = getTimezoneFromTimePrefs(timePrefs); const endOfToday = moment.utc().tz(timezoneName).endOf('day').subtract(1, 'ms'); @@ -59,14 +51,7 @@ const getOpts = ( }); }; - const defaultDates = () => ({ - agpBGM: getLastNDays(agpPeriodInDays, 'agpBGM'), - agpCGM: getLastNDays(agpPeriodInDays, 'agpCGM'), - - // 'trends', 'bgLog', 'daily', and 'basics' omitted - }); - - const dates = defaultDates(); + const dates = getLastNDays(agpPeriodInDays, 'agpCGM'); const formatDateEndpoints = ({ startDate, endDate }) => (startDate && endDate ? [ startDate.valueOf(), @@ -74,9 +59,8 @@ const getOpts = ( ] : []); const opts = { - agpCGM: { disabled: false, endpoints: formatDateEndpoints(dates.agpCGM) }, - agpBGM: { disabled: false, endpoints: formatDateEndpoints(dates.agpBGM) }, - + agpCGM: { disabled: false, endpoints: formatDateEndpoints(dates) }, + agpBGM: { disabled: true }, basics: { disabled: true }, bgLog: { disabled: true }, daily: { disabled: true }, diff --git a/app/pages/dashboard/PatientDrawer/useAgpCGM/getQueries.js b/app/pages/dashboard/PatientDrawer/useAgpCGM/getQueries.js index b3bc9222f7..ef04b9ee60 100644 --- a/app/pages/dashboard/PatientDrawer/useAgpCGM/getQueries.js +++ b/app/pages/dashboard/PatientDrawer/useAgpCGM/getQueries.js @@ -1,19 +1,8 @@ import _ from 'lodash'; import { utils as vizUtils } from '@tidepool/viz'; -import utils from '../../../../core/utils'; +const { commonStats } = vizUtils.stat; -const chartPrefs = { - agpBGM: { - bgSource: 'smbg', - }, - agpCGM: { - bgSource: 'cbg', - }, - settings: { - touched: false, - }, - excludedDevices: [], -}; +import utils from '../../../../core/utils'; const getQueries = ( data, @@ -44,65 +33,30 @@ const getQueries = ( return localTimePrefs; })(); - const getStatsByChartType = (chartType, _bgSource) => { - const { commonStats } = vizUtils.stat; - - let stats = []; - - switch (chartType) { // cases 'basics', 'trends', 'bgLog', and 'daily' omitted - case 'agpBGM': - stats.push(commonStats.averageGlucose,); - stats.push(commonStats.bgExtents,); - stats.push(commonStats.coefficientOfVariation,); - stats.push(commonStats.glucoseManagementIndicator,); - stats.push(commonStats.readingsInRange,); - break; - - case 'agpCGM': - stats.push(commonStats.averageGlucose); - stats.push(commonStats.bgExtents); - stats.push(commonStats.coefficientOfVariation); - stats.push(commonStats.glucoseManagementIndicator); - stats.push(commonStats.sensorUsage); - stats.push(commonStats.timeInRange); - break; - } - - return stats; - } - - const commonQueries = { - bgPrefs: bgPrefs, - metaData: 'latestPumpUpload, bgSources', - timePrefs: timePrefs, - excludedDevices: chartPrefs?.excludedDevices, - }; - - const queries = {} - - if (!opts.agpBGM?.disabled) { - queries.agpBGM = { - endpoints: opts.agpBGM?.endpoints, - aggregationsByDate: 'dataByDate, statsByDate', - bgSource: _.get(chartPrefs, 'agpBGM.bgSource'), - stats: getStatsByChartType('agpBGM'), - types: { smbg: {} }, - ...commonQueries, - }; - } - - if (!opts.agpCGM?.disabled) { - queries.agpCGM = { + let stats = [ + commonStats.averageGlucose, + commonStats.bgExtents, + commonStats.coefficientOfVariation, + commonStats.glucoseManagementIndicator, + commonStats.sensorUsage, + commonStats.timeInRange, + ]; + + const queries = { + agpCGM: { endpoints: opts.agpCGM?.endpoints, aggregationsByDate: 'dataByDate, statsByDate', - bgSource: _.get(chartPrefs, 'agpCGM.bgSource'), - stats: getStatsByChartType('agpCGM'), + bgSource: 'cbg', + stats, types: { cbg: {} }, - ...commonQueries, - }; - } + bgPrefs, + metaData: 'latestPumpUpload, bgSources', + timePrefs, + excludedDevices: [], + }, + }; return queries; -} +}; export default getQueries; \ No newline at end of file diff --git a/app/pages/dashboard/PatientDrawer/useAgpCGM/useAgpCGM.js b/app/pages/dashboard/PatientDrawer/useAgpCGM/useAgpCGM.js index 9bcdc62fa6..3fc072d71e 100644 --- a/app/pages/dashboard/PatientDrawer/useAgpCGM/useAgpCGM.js +++ b/app/pages/dashboard/PatientDrawer/useAgpCGM/useAgpCGM.js @@ -17,7 +17,7 @@ export const STATUS = { // Other states NO_PATIENT_DATA: 'NO_PATIENT_DATA', INSUFFICIENT_DATA: 'INSUFFICIENT_DATA', -} +}; // TODO: Revisit best way to listen for progress when we move away from blip.working const inferLastCompletedStep = (patientId, data, pdf) => { @@ -33,9 +33,11 @@ const inferLastCompletedStep = (patientId, data, pdf) => { // Insufficient Data States --- const hasNoPatientData = data.metaData?.size === 0; const hasInsufficientData = !!pdf?.opts?.svgDataURLS && !pdf?.opts?.svgDataURLS.agpCGM; + const hasNoCGMData = !!pdf?.combined && !pdf?.data; if (hasNoPatientData) return STATUS.NO_PATIENT_DATA; if (hasInsufficientData) return STATUS.INSUFFICIENT_DATA; + if (hasNoCGMData) return STATUS.INSUFFICIENT_DATA; // Happy Path States --- const hasImagesInState = !!pdf?.opts?.svgDataURLS; @@ -47,7 +49,7 @@ const inferLastCompletedStep = (patientId, data, pdf) => { if (hasPatientInState) return STATUS.PATIENT_LOADED; return STATUS.STATE_CLEARED; -} +}; const FETCH_PATIENT_OPTS = { forceDataWorkerAddDataRequest: true, useCache: false }; @@ -104,7 +106,7 @@ const useAgpCGM = ( return () => { dispatch(actions.worker.removeGeneratedPDFS()); dispatch(actions.worker.dataWorkerRemoveDataRequest(null, patientId)); - } + }; }, []); // Note: probably unnecessary; failsafe to ensure that data is being returned for correct patient @@ -115,6 +117,6 @@ const useAgpCGM = ( svgDataURLS: isCorrectPatientInState ? pdf.opts?.svgDataURLS : null, agpCGM: isCorrectPatientInState ? pdf.data?.agpCGM : null, }; -} +}; export default useAgpCGM; \ No newline at end of file diff --git a/app/pages/dashboard/TideDashboard.js b/app/pages/dashboard/TideDashboard.js index 14bc8b12a2..f85a96502b 100644 --- a/app/pages/dashboard/TideDashboard.js +++ b/app/pages/dashboard/TideDashboard.js @@ -49,6 +49,7 @@ import { TagList } from '../../components/elements/Tag'; 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 Popover from '../../components/elements/Popover'; import PopoverMenu from '../../components/elements/PopoverMenu'; import RadioGroup from '../../components/elements/RadioGroup'; @@ -77,6 +78,7 @@ import { } from '../../core/clinicUtils'; import { DEFAULT_FILTER_THRESHOLDS, MGDL_UNITS, MMOLL_UNITS } from '../../core/constants'; +import DataInIcon from '../../core/icons/DataInIcon.svg'; import { colors, fontWeights, radii } from '../../themes/baseTheme'; import PatientLastReviewed from '../../components/clinic/PatientLastReviewed'; @@ -106,18 +108,29 @@ const editPatient = (patient, setSelectedPatient, selectedClinicId, trackMetric, setShowEditPatientDialog(true); }; +const editPatientDataConnections = (patient, setSelectedPatient, selectedClinicId, trackMetric, setShowDataConnectionsModal, source) => { + trackMetric('Clinic - Edit patient data connections', { clinicId: selectedClinicId, source }); + setSelectedPatient(patient); + setShowDataConnectionsModal(true); +}; + const MoreMenu = React.memo(({ patient, selectedClinicId, t, trackMetric, setSelectedPatient, + setShowDataConnectionsModal, setShowEditPatientDialog, }) => { const handleEditPatient = useCallback(() => { editPatient(patient, setSelectedPatient, selectedClinicId, trackMetric, setShowEditPatientDialog, 'action menu'); }, [patient, setSelectedPatient, selectedClinicId, trackMetric, setShowEditPatientDialog]); + const handleEditPatientDataConnections = useCallback(() => { + editPatientDataConnections(patient, setSelectedPatient, selectedClinicId, trackMetric, setShowDataConnectionsModal, 'action menu'); + }, [patient, setSelectedPatient, selectedClinicId, trackMetric, setShowDataConnectionsModal]); + const items = useMemo(() => ([{ icon: EditIcon, iconLabel: t('Edit Patient Information'), @@ -129,6 +142,17 @@ const MoreMenu = React.memo(({ handleEditPatient(patient); }, text: t('Edit Patient Information'), + }, { + iconSrc: DataInIcon, + iconLabel: t('Bring Data into Tidepool'), + iconPosition: 'left', + id: `edit-data-connections-${patient.id}`, + variant: 'actionListItem', + onClick: (_popupState) => { + _popupState.close(); + handleEditPatientDataConnections(patient); + }, + text: t('Bring Data into Tidepool'), }]), [ handleEditPatient, patient, @@ -226,8 +250,7 @@ const SortPopover = React.memo(props => { ) -}) - +}); const TideDashboardSection = React.memo(props => { const { @@ -246,6 +269,7 @@ const TideDashboardSection = React.memo(props => { selectedClinicId, setSections, setSelectedPatient, + setShowDataConnectionsModal, setShowEditPatientDialog, showTideDashboardLastReviewed, showTideDashboardPatientDrawer, @@ -442,6 +466,7 @@ const TideDashboardSection = React.memo(props => { t={t} trackMetric={trackMetric} setSelectedPatient={setSelectedPatient} + setShowDataConnectionsModal={setShowDataConnectionsModal} setShowEditPatientDialog={setShowEditPatientDialog} prefixTideDashboardMetric={prefixTideDashboardMetric} />; @@ -754,6 +779,7 @@ export const TideDashboard = (props) => { const location = useLocation(); const history = useHistory(); const [showTideDashboardConfigDialog, setShowTideDashboardConfigDialog] = useState(false); + const [showDataConnectionsModal, setShowDataConnectionsModal] = useState(false); const [showEditPatientDialog, setShowEditPatientDialog] = useState(false); const [selectedPatient, setSelectedPatient] = useState(null); const [loading, setLoading] = useState(false); @@ -797,6 +823,17 @@ export const TideDashboard = (props) => { const [sections, setSections] = useState(defaultSections); + function handleCloseOverlays() { + setShowTideDashboardConfigDialog(false); + setShowDataConnectionsModal(false); + setShowEditPatientDialog(false); + + setTimeout(() => { + setPatientFormContext(null); + setSelectedPatient(null); + }); + } + const handleAsyncResult = useCallback((workingState, successMessage, onComplete = handleCloseOverlays) => { const { inProgress, completed, notification, prevInProgress } = workingState; @@ -820,23 +857,29 @@ export const TideDashboard = (props) => { } }, [isFirstRender, setToast]); - useEffect(() => { - handleAsyncResult({ ...updatingClinicPatient, prevInProgress: previousUpdatingClinicPatient?.inProgress }, t('You have successfully updated a patient.'), () => { + const handlePatientEdited = useCallback(() => { + if (patientFormContext?.status?.showDataConnectionsModalNext) { + setShowEditPatientDialog(false); + editPatientDataConnections(selectedPatient, setSelectedPatient, selectedClinicId, trackMetric, setShowDataConnectionsModal, 'Tide dashboard - patient modal'); + } else { handleCloseOverlays(); + } + }, [handleCloseOverlays, patientFormContext?.status]); - if (patientFormContext?.status === 'sendingDexcomConnectRequest') { - dispatch(actions.async.sendPatientDexcomConnectRequest(api, selectedClinicId, updatingClinicPatient.patientId)); - } - }); + useEffect(() => { + // Only process detected updates if patient edit form is showing. Other child components, such as + // the PatientEmailModal, may also update the patient, and handle the results + if (showEditPatientDialog) { + handleAsyncResult({ ...updatingClinicPatient, prevInProgress: previousUpdatingClinicPatient?.inProgress }, t('You have successfully updated a patient.'), handlePatientEdited) + } }, [ - api, - dispatch, - selectedClinicId, handleAsyncResult, + handlePatientEdited, t, updatingClinicPatient, patientFormContext?.status, previousUpdatingClinicPatient?.inProgress, + showEditPatientDialog, ]); // Provide latest patient state for the edit form upon fetch @@ -930,6 +973,12 @@ export const TideDashboard = (props) => { patientFormContext?.handleSubmit(); }, [patientFormContext, selectedClinicId, trackMetric, selectedPatient?.tags]); + const handleEditPatientAndAddDataSourcesConfirm = useCallback(() => { + trackMetric('Clinic - Edit patient next', { clinicId: selectedClinicId, source: 'Tide dashboard' }); + patientFormContext?.setStatus({ showDataConnectionsModalNext: true }); + handleEditPatientConfirm(); + }, [patientFormContext, selectedClinicId, trackMetric, handleEditPatientConfirm]); + const handleClosePatientDrawer = useCallback(() => { const { search, pathname } = location; @@ -1107,17 +1156,27 @@ export const TideDashboard = (props) => { + +