Skip to content

Commit

Permalink
Merge pull request #1420 from tidepool-org/WEB-3022-palmtree-guardrails
Browse files Browse the repository at this point in the history
[WEB-3022] Implement Palmtree guardrails
  • Loading branch information
clintonium-119 authored Aug 28, 2024
2 parents 61493ec + 264a682 commit 4f8c87e
Show file tree
Hide file tree
Showing 10 changed files with 357 additions and 84 deletions.
69 changes: 44 additions & 25 deletions app/pages/prescription/PrescriptionForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { withTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import { push } from 'connected-react-router';
import bows from 'bows';
import moment from 'moment';
Expand Down Expand Up @@ -48,7 +49,7 @@ import i18next from '../../core/language';
import { useToasts } from '../../providers/ToastProvider';
import { Headline } from '../../components/elements/FontStyles';
import { borders } from '../../themes/baseTheme';
import { useIsFirstRender } from '../../core/hooks';
import { useIsFirstRender, useLocalStorage } from '../../core/hooks';
import * as actions from '../../redux/actions';
import { components as vizComponents } from '@tidepool/viz';

Expand Down Expand Up @@ -263,25 +264,29 @@ export const PrescriptionForm = props => {
t,
api,
devices,
history,
location,
prescription,
trackMetric,
} = props;

const dispatch = useDispatch();
const formikContext = useFormikContext();
const { id } = useParams();
const isNewPrescriptionFlow = () => isEmpty(id);

const {
handleSubmit,
resetForm,
setFieldValue,
setValues,
setStatus,
status,
values,
} = formikContext;

const isFirstRender = useIsFirstRender();
const storageKey = 'prescriptionForm';
const [storedValues, setStoredValues] = useLocalStorage(storageKey);
const { set: setToast } = useToasts();
const loggedInUserId = useSelector((state) => state.blip.loggedInUserId);
const selectedClinicId = useSelector((state) => state.blip.selectedClinicId);
Expand Down Expand Up @@ -325,7 +330,6 @@ export const PrescriptionForm = props => {
const params = () => new URLSearchParams(location.search);
const activeStepParamKey = `${stepperId}-step`;
const activeStepsParam = params().get(activeStepParamKey);
const storageKey = 'prescriptionForm';

const [formPersistReady, setFormPersistReady] = useState(false);
const [stepAsyncState, setStepAsyncState] = useState(asyncStates.initial);
Expand All @@ -337,7 +341,6 @@ export const PrescriptionForm = props => {
const isSingleStepEdit = !!pendingStep.length;
const validationFields = [ ...stepValidationFields ];
const isLastStep = () => activeStep === validationFields.length - 1;
const isNewPrescription = isEmpty(get(values, 'id'));

const handlers = {
activeStepUpdate: ([step, subStep], fromStep = [], initialFocusedInput) => {
Expand Down Expand Up @@ -420,14 +423,14 @@ export const PrescriptionForm = props => {
setFieldValue('state', prescriptionAttributes.state);

prescriptionAttributes.revisionHash = await sha512(
canonicalize(prescriptionAttributes),
canonicalize(omit(prescriptionAttributes, 'createdUserId')),
{ outputFormat: 'hex' }
);

if (isNewPrescription) {
dispatch(actions.async.createPrescription(api, selectedClinicId, prescriptionAttributes));
} else {
if (values.id) {
dispatch(actions.async.createPrescriptionRevision(api, selectedClinicId, prescriptionAttributes, values.id));
} else {
dispatch(actions.async.createPrescription(api, selectedClinicId, prescriptionAttributes));
}
},
};
Expand Down Expand Up @@ -501,16 +504,33 @@ export const PrescriptionForm = props => {
}

useEffect(() => {
let initialValues = { ...values }

// Hydrate the locally stored values only in the following cases, allowing us to persist data
// entered in form substeps but not yet saved to the database
// 1. It's a new prescription and there is no locally stored id, and there are step and substep query params
// 2. We're editing an existing prescription, and the locally stored id matches the id in the url param
if (
(isNewPrescriptionFlow() && !storedValues?.id && !isUndefined(activeStep) && !isUndefined(activeSubStep)) ||
(id && id === storedValues?.id)
) {
initialValues = { ...values, ...storedValues };
setValues(initialValues);
}

// After hydrating any relevant values, we delete the localStorage values so formikPersist has a clean start
delete localStorage[storageKey];

// Determine the latest incomplete step, and default to starting there
if (isEditable && (isUndefined(activeStep) || isUndefined(activeSubStep))) {
if (isEditable) {
let firstInvalidStep;
let firstInvalidSubStep;
let currentStep = 0;
let currentSubStep = 0;

while (isUndefined(firstInvalidStep) && currentStep < validationFields.length) {
while (currentSubStep < validationFields[currentStep].length) {
if (!fieldsAreValid(validationFields[currentStep][currentSubStep], schema, values)) {
if (!fieldsAreValid(validationFields[currentStep][currentSubStep], schema, initialValues)) {
firstInvalidStep = currentStep;
firstInvalidSubStep = currentSubStep;
break;
Expand All @@ -526,15 +546,9 @@ export const PrescriptionForm = props => {
setActiveSubStep(isInteger(firstInvalidSubStep) ? firstInvalidSubStep : 0);
}

// When a user comes to this component initially, without the active step and subStep set by the
// Stepper component in the url, or when editing an existing prescription,
// we delete any persisted state from localStorage.
if (status.isPrescriptionEditFlow || (get(localStorage, storageKey) && activeStepsParam === null)) {
delete localStorage[storageKey];
}

// Only use the localStorage persistence for new prescriptions - not while editing an existing one.
setFormPersistReady(!prescription);
// Now that any hydration is complete and we've cleared locally stored values,
// we're ready for formikPersist to take over form persistence
setFormPersistReady(true);
}, []);

// Save whether or not we are editing a single step to the formik form status for easy reference
Expand All @@ -556,14 +570,13 @@ export const PrescriptionForm = props => {
// Handle changes to stepper async state for completed prescription creation and revision updates
useEffect(() => {
const isRevision = !!get(values, 'id');
const isDraft = get(values, 'state') === 'draft';
const { inProgress, completed, notification, prescriptionId } = isRevision ? creatingPrescriptionRevision : creatingPrescription;

if (prescriptionId) setFieldValue('id', prescriptionId);

if (!isFirstRender && !inProgress) {
if (completed) {
setStepAsyncState(asyncStates.completed);
if (prescriptionId) setFieldValue('id', prescriptionId);

if (isLastStep()) {

let messageAction = isRevision ? t('updated') : t('created');
Expand All @@ -574,7 +587,13 @@ export const PrescriptionForm = props => {
variant: 'success',
});

history.push('/clinic-workspace/prescriptions');
dispatch(push('/clinic-workspace/prescriptions'));
} else {
if (prescriptionId && isNewPrescriptionFlow()) {
// Redirect to normal prescription edit flow once we have a prescription ID
setStoredValues({ ...values, id: prescriptionId })
dispatch(push(`/prescriptions/${prescriptionId}`));
}
}
}

Expand Down Expand Up @@ -631,7 +650,7 @@ export const PrescriptionForm = props => {
},
};

const title = isNewPrescription ? t('Create New Prescription') : t('Prescription: {{name}}', {
const title = isNewPrescriptionFlow() ? t('Create New Prescription') : t('Prescription: {{name}}', {
name: [values.firstName, values.lastName].join(' '),
});

Expand All @@ -655,7 +674,7 @@ export const PrescriptionForm = props => {
<Button
id="back-to-prescriptions"
variant="primary"
onClick={() => props.history.push('/clinic-workspace/prescriptions')}
onClick={() => dispatch(push('/clinic-workspace/prescriptions'))}
mr={5}
>
{t('Back To Prescriptions')}
Expand Down
44 changes: 32 additions & 12 deletions app/pages/prescription/ScheduleForm.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import React from 'react';
import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
import { withTranslation } from 'react-i18next';
import { FastField, Field, useFormikContext } from 'formik';
import { Box, Flex, Text, BoxProps } from 'theme-ui';
import filter from 'lodash/filter';
import get from 'lodash/get';
import includes from 'lodash/includes';
import map from 'lodash/map';
import isInteger from 'lodash/isInteger';
import sortedLastIndexBy from 'lodash/sortedLastIndexBy';
Expand All @@ -15,6 +17,7 @@ import i18next from '../../core/language';
import TextInput from '../../components/elements/TextInput';
import Icon from '../../components/elements/Icon';
import Button from '../../components/elements/Button';
import Select from '../../components/elements/Select';
import { MS_IN_MIN, MS_IN_DAY } from '../../core/constants';
import { convertMsPer24ToTimeString, convertTimeStringToMsPer24 } from '../../core/datetime';
import { inlineInputStyles } from './prescriptionFormStyles';
Expand All @@ -27,6 +30,8 @@ const ScheduleForm = props => {
addButtonText,
fieldArrayName,
fields,
max,
minutesIncrement,
separator,
t,
useFastField,
Expand All @@ -46,6 +51,8 @@ const ScheduleForm = props => {

const [schedules, , { move, remove, replace, push }] = useFieldArray({ name: fieldArrayName });
const schedulesLength = schedules.value.length;
const lastSchedule = useMemo(() => schedules.value[schedulesLength - 1], [schedules.value, schedulesLength]);
const msIncrement = minutesIncrement * MS_IN_MIN;

React.useEffect(() => {
// add or remove refs as the schedule length changes
Expand All @@ -60,21 +67,32 @@ const ScheduleForm = props => {

const FieldElement = useFastField ? FastField : Field;

const timeOptions = [];

for (let startTime = msIncrement; startTime <= (MS_IN_DAY - (msIncrement)); startTime += msIncrement) {
timeOptions.push({ label: convertMsPer24ToTimeString(startTime, 'hh:mm'), value: startTime });
}

const selectedTimes = useMemo(() => map(schedules.value, 'start'), [schedules.value]);

let availableTimeOptions = (currentStart, previousStart) => filter(timeOptions, option => {
return option.value === currentStart || (option.value > previousStart && !includes(selectedTimes, option.value));
});

return (
<Box {...boxProps}>
{map(schedules.value, (schedule, index) => (
<Flex className='schedule-row' key={index} sx={{ alignItems: 'flex-start' }} mb={3}>
<Field
as={TextInput}
as={index === 0 ? TextInput : Select}
label={index === 0 ? t('Start Time') : null}
type="time"
options={availableTimeOptions(schedule.start, schedules.value[index - 1]?.start || 0)}
readOnly={index === 0}
step={MS_IN_MIN * 30 / 1000}
value={convertMsPer24ToTimeString(schedule.start, 'hh:mm')}
value={index === 0 ? convertMsPer24ToTimeString(schedule.start) : schedule.start}
onChange={e => {
const start = convertTimeStringToMsPer24(e.target.value);
const newValue = {...schedules.value[index], start};
const valuesCopy = [...schedules.value];
const start = index === 0 ? convertTimeStringToMsPer24(e.target.value) : parseInt(e.target.value, 10);
const newValue = { ...schedules.value[index], start };
const valuesCopy = [ ...schedules.value ];
valuesCopy.splice(index, 1);
const newPos = sortedLastIndexBy(valuesCopy, newValue, function(o) { return o.start; });
replace(index, newValue);
Expand Down Expand Up @@ -140,14 +158,12 @@ const ScheduleForm = props => {
},
}}
disabled={(() => {
const lastSchedule = schedules.value[schedules.value.length - 1];
return lastSchedule.start >= (MS_IN_DAY - (MS_IN_MIN * 30));
return (schedulesLength >= max) || (lastSchedule.start >= (MS_IN_DAY - (msIncrement)));
})()}
onClick={() => {
const lastSchedule = schedules.value[schedules.value.length - 1];
return push({
...lastSchedule,
start: lastSchedule.start + (MS_IN_MIN * 30),
start: lastSchedule.start + (msIncrement),
});
}}
>
Expand All @@ -171,6 +187,8 @@ ScheduleForm.propTypes = {
suffix: PropTypes.string,
type: PropTypes.string,
})),
max: PropTypes.number,
minutesIncrement: PropTypes.number,
separator: PropTypes.string,
useFastField: PropTypes.bool,
};
Expand All @@ -179,6 +197,8 @@ ScheduleForm.defaultProps = {
addButtonText: t('Add another'),
fields: [],
useFastField: false,
max: 48,
minutesIncrement: 30,
};

export default withTranslation()(ScheduleForm);
40 changes: 32 additions & 8 deletions app/pages/prescription/prescriptionFormConstants.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,15 @@ export const roundValueToIncrement = (value, increment = 1) => {
};

export const pumpRanges = (pump, bgUnits = defaultUnits.bloodGlucose, values) => {
const isPalmtree = pump?.id === deviceIdMap.palmtree;
const maxBasalRate = max(map(get(values, 'initialSettings.basalRateSchedule'), 'rate'));

const ranges = {
basalRate: {
min: max([getPumpGuardrail(pump, 'basalRates.absoluteBounds.minimum', 0.05), 0.05]),
max: min([getPumpGuardrail(pump, 'basalRates.absoluteBounds.maximum', 30), 30]),
increment: getPumpGuardrail(pump, 'basalRates.absoluteBounds.increment', 0.05),
schedules: { max: isPalmtree ? 24 : 48, minutesIncrement: 30 },
},
basalRateMaximum: {
min: max(filter([
Expand All @@ -113,7 +117,10 @@ export const pumpRanges = (pump, bgUnits = defaultUnits.bloodGlucose, values) =>
], isFinite)),
max: min(filter([
getPumpGuardrail(pump, 'basalRateMaximum.absoluteBounds.maximum', 30),
70 / min(map(get(values, 'initialSettings.carbohydrateRatioSchedule'), 'amount')),
max([
70 / min(map(get(values, 'initialSettings.carbohydrateRatioSchedule'), 'amount')),
parseFloat((maxBasalRate * 6.4).toFixed(2))
]),
], isFinite)),
increment: getPumpGuardrail(pump, 'basalRateMaximum.absoluteBounds.increment', 0.05),
},
Expand All @@ -124,6 +131,7 @@ export const pumpRanges = (pump, bgUnits = defaultUnits.bloodGlucose, values) =>
], isFinite)),
max: getBgInTargetUnits(getPumpGuardrail(pump, 'correctionRange.absoluteBounds.maximum', 180), MGDL_UNITS, bgUnits),
increment: getBgStepInTargetUnits(getPumpGuardrail(pump, 'correctionRange.absoluteBounds.increment', 1), MGDL_UNITS, bgUnits),
schedules: { max: 48, minutesIncrement: 30 },
},
bloodGlucoseTargetPhysicalActivity: {
min: max(filter([
Expand All @@ -149,13 +157,14 @@ export const pumpRanges = (pump, bgUnits = defaultUnits.bloodGlucose, values) =>
carbRatio: {
min: getPumpGuardrail(pump, 'carbohydrateRatio.absoluteBounds.minimum', 2),
max: getPumpGuardrail(pump, 'carbohydrateRatio.absoluteBounds.maximum', 150),
increment: getPumpGuardrail(pump, 'carbohydrateRatio.absoluteBounds.increment', 0.01),
inputStep: 1,
increment: getPumpGuardrail(pump, 'carbohydrateRatio.absoluteBounds.increment', 0.1),
schedules: { max: 48, minutesIncrement: 30 },
},
insulinSensitivityFactor: {
min: getBgInTargetUnits(getPumpGuardrail(pump, 'insulinSensitivity.absoluteBounds.minimum', 10), MGDL_UNITS, bgUnits),
max: getBgInTargetUnits(getPumpGuardrail(pump, 'insulinSensitivity.absoluteBounds.maximum', 500), MGDL_UNITS, bgUnits),
increment: getBgStepInTargetUnits(getPumpGuardrail(pump, 'insulinSensitivity.absoluteBounds.increment', 1), MGDL_UNITS, bgUnits),
schedules: { max: 48, minutesIncrement: 30 },
},
glucoseSafetyLimit: {
min: getBgInTargetUnits(getPumpGuardrail(pump, 'glucoseSafetyLimit.absoluteBounds.minimum', 67), MGDL_UNITS, bgUnits),
Expand Down Expand Up @@ -299,16 +308,31 @@ export const defaultValues = (pump, bgUnits = defaultUnits.bloodGlucose, values
const maxBasalRate = max(map(get(values, 'initialSettings.basalRateSchedule'), 'rate'));
const patientAge = moment().diff(moment(get(values, 'birthday'), dateFormat), 'years', true);
const isPediatric = patientAge < 18;
const isPalmtree = pump.id === deviceIdMap.palmtree;

let bloodGlucoseTarget = {
low: getBgInTargetUnits(100, MGDL_UNITS, bgUnits),
high: getBgInTargetUnits(105, MGDL_UNITS, bgUnits),
};

if (isPalmtree) {
bloodGlucoseTarget = {
low: getBgInTargetUnits(115, MGDL_UNITS, bgUnits),
high: getBgInTargetUnits(125, MGDL_UNITS, bgUnits),
};
} else if (isPediatric) {
bloodGlucoseTarget = {
low: getBgInTargetUnits(100, MGDL_UNITS, bgUnits),
high: getBgInTargetUnits(115, MGDL_UNITS, bgUnits),
};
}

return {
basalRate: recommendedBasalRate || 0.05,
basalRateMaximum: isFinite(maxBasalRate)
? parseFloat((maxBasalRate * (isPediatric ? 3 : 3.5)).toFixed(2))
: getPumpGuardrail(pump, 'basalRateMaximum.defaultValue', 0.05),
bloodGlucoseTarget: {
low: getBgInTargetUnits(100, MGDL_UNITS, bgUnits),
high: getBgInTargetUnits(isPediatric ? 115 : 105, MGDL_UNITS, bgUnits),
},
bloodGlucoseTarget,
bloodGlucoseTargetPhysicalActivity: {
low: getBgInTargetUnits(150, MGDL_UNITS, bgUnits),
high: getBgInTargetUnits(170, MGDL_UNITS, bgUnits),
Expand All @@ -319,7 +343,7 @@ export const defaultValues = (pump, bgUnits = defaultUnits.bloodGlucose, values
},
carbohydrateRatio: recommendedCarbohydrateRatio,
insulinSensitivity: recommendedInsulinSensitivity,
glucoseSafetyLimit: getBgInTargetUnits(isPediatric ? 80 : 75, MGDL_UNITS, bgUnits),
glucoseSafetyLimit: getBgInTargetUnits(isPediatric || isPalmtree ? 80 : 75, MGDL_UNITS, bgUnits),
};
};

Expand Down
Loading

0 comments on commit 4f8c87e

Please sign in to comment.