import 'react-native-get-random-values'; import { v4 as uuidv4 } from 'uuid'; import { produce } from 'immer'; import { clone, groupBy, flatten } from 'ramda'; import { compareDesc, format } from 'date-fns'; import { actionTypes } from '../action-types'; import { PLURAL_RESOURCE_TYPES, TYPES_SORTED_BY_LABEL } from '../../constants/resource-types'; import { DEFAULT_COLLECTION_NAME } from '../../constants'; import { UNMARKED, MARKED, FOCUSED } from '../../constants/marked-status'; import { SORT_ASC, SORT_DESC, sortFields } from '../../constants/sorting'; const preloadedResources = {}; export const flattenedResourcesReducer = (state = preloadedResources, { type, payload }) => { switch (type) { case actionTypes.CLEAR_PATIENT_DATA: { return preloadedResources; } case actionTypes.RESOURCE_BATCH: { Object.entries(payload.resources).forEach(([id, resource]) => { if (state[id]) { console.warn(`resource ${id} of type ${resource.resourceType} already added.`); // eslint-disable-line no-console } }); return { ...state, ...payload.resources, }; } default: return state; } }; const defaultAssociations = { encounters: {}, }; export const associationsReducer = (state = defaultAssociations, { type, payload }) => { switch (type) { case actionTypes.CLEAR_PATIENT_DATA: { return defaultAssociations; } case actionTypes.RESOURCE_BATCH: { const encounters = {}; Object.entries(payload.resources).forEach(([id, resource]) => { if (state[id]) { console.warn(`resource ${id} of type ${resource.resourceType} already processed.`); // eslint-disable-line no-console } const encounterUrn = resource.encounter?.reference; if (encounterUrn) { const matches = encounterUrn.match(/(#|\/)(.+)/); const encounterId = matches.pop(); if (encounterId) { encounters[id] = encounterId; } } }); return produce(state, (draft) => { // eslint-disable-next-line no-param-reassign draft.encounters = { ...state.encounters, ...encounters }; }); } default: return state; } }; const { RECORD_TYPE, RECORD_DATE, TIME_SAVED } = sortFields; const defaultDetailsPanelSortingState = { activeSortField: RECORD_TYPE, sortDirections: { [RECORD_TYPE]: SORT_DESC, [RECORD_DATE]: SORT_DESC, [TIME_SAVED]: SORT_DESC, }, }; // prune items whose values are 0, null, undefined, or empty string: // const pruneEmpty = ((o) => Object.entries(o) // .filter(([, v]) => v) // .reduce((acc, [id, v]) => ({ ...acc, [id]: v }), {})); export const createCollection = (options = {}) => { const { label = DEFAULT_COLLECTION_NAME, id = uuidv4(), preBuilt = false, showCollectionOnly = false, selectedResourceType = TYPES_SORTED_BY_LABEL[0], purpose = '', current = false, urgent = false, tags = [], } = options; const timeCreated = new Date(); return { id, preBuilt, created: timeCreated, lastUpdated: timeCreated, label, selectedResourceType, resourceTypeFilters: TYPES_SORTED_BY_LABEL .reduce((acc, resourceType) => ({ ...acc, [resourceType]: true, }), {}), dateRangeFilter: { dateRangeStart: undefined, dateRangeEnd: undefined, }, showCollectionOnly, showMarkedOnly: false, focusedSubtype: '', records: {}, detailsPanelSortingState: defaultDetailsPanelSortingState, notes: {}, tags, purpose, current, urgent, }; }; const createNewCollectionRecord = () => ({ saved: false, dateSaved: new Date(), highlight: UNMARKED, // highlight: // 0 -- unmarked // 1 -- marked // 2 -- focused }); let defaultCollection = createCollection(); const preloadCollections = { [defaultCollection.id]: defaultCollection, }; const getNextEnabledType = (resourceType, enabledTypes) => enabledTypes .map((type, index, array) => ({ type, next: array[(index === array.length - 1) ? 0 : index + 1], })) .find(({ type }) => type === resourceType) ?.next; const createNote = (text) => { const newDate = new Date(); return { id: uuidv4(), text, dateCreated: newDate, dateEdited: newDate, }; }; const sortByDateDesc = ({ timelineDate: t1 }, { timelineDate: t2 }) => compareDesc(t1, t2); const groupRecordsByDay = groupBy((record) => { const isoDate = format(record.timelineDate, 'yyyy-MM-dd'); return isoDate; }); const lastNRecordIdsGroupedByDay = (records, count) => { const lastNSorted = Object.entries(groupRecordsByDay(records)) .sort(([k1], [k2]) => ((k1 > k2) ? -1 : 1)) .slice(0, count) .map(([, recordsOnDay]) => recordsOnDay) .map((recordsOnDay) => recordsOnDay); return flatten(lastNSorted).map((recordsOnDay) => recordsOnDay.id); }; const disabledActionsForPreBuilt = [ actionTypes.TOGGLE_SHOW_COLLECTION_ONLY, actionTypes.ADD_RESOURCE_TO_COLLECTION, actionTypes.REMOVE_RESOURCE_FROM_COLLECTION, actionTypes.RENAME_COLLECTION, actionTypes.CLEAR_COLLECTION, actionTypes.DELETE_COLLECTION, ]; export const PREBUILT_COLLECTIONS_LABELS = { lastEncounters: 'lastEncounters', lastLabResults: 'lastLabResults', lastVitalSigns: 'lastVitalSigns', }; export const collectionsReducer = (state = preloadCollections, action) => { const { collectionId } = action.payload || {}; if (collectionId && state[collectionId]) { const { preBuilt } = state[collectionId]; if (preBuilt && disabledActionsForPreBuilt.includes(action.type)) { console.warn(`Collection ${collectionId} is pre-built -- cannot apply action ${action.type}`); // eslint-disable-line no-console return state; } } switch (action.type) { case actionTypes.CLEAR_PATIENT_DATA: { const { id } = defaultCollection; defaultCollection = createCollection(); defaultCollection.id = id; return { [defaultCollection.id]: defaultCollection, }; } case actionTypes.BUILD_DEFAULT_COLLECTIONS: { return produce(state, (draft) => { const { resources, associations: { encounters } } = action.payload; const sortedResources = Object.values(resources) .filter(({ type }) => PLURAL_RESOURCE_TYPES[type]) .filter((r) => r.timelineDate) // must have timelineDate .sort(sortByDateDesc); const updateOrCreateCollection = ({ id, label, selectedResourceType, recordIds, }) => { /* eslint-disable no-param-reassign */ draft[id] = draft[id] ?? createCollection({ id, label, preBuilt: true, showCollectionOnly: true, selectedResourceType, }); const { records } = draft[id]; Object.values(records).forEach((record) => { // un-save, in case it is no longer part of preBuilt: record.saved = recordIds.includes(record.id); }); recordIds.forEach((rId) => { records[rId] = records[rId] ?? createNewCollectionRecord(); records[rId].saved = true; }); /* eslint-enable no-param-reassign */ }; const lastEncounters = sortedResources.filter((item) => item.type === 'Encounter').slice(0, 3).map(({ id }) => id); const referencesEncounters = Object.entries(encounters) .reduce((acc, [recordId, encounterId]) => { if (lastEncounters.includes(encounterId)) { return acc.concat(recordId); } return acc; }, []); updateOrCreateCollection({ id: PREBUILT_COLLECTIONS_LABELS.lastEncounters, label: 'Last Encounters', selectedResourceType: 'Encounter', recordIds: lastEncounters.concat(referencesEncounters), }); const laboratories = sortedResources.filter((item) => item.type === 'laboratory'); updateOrCreateCollection({ id: PREBUILT_COLLECTIONS_LABELS.lastLabResults, label: 'Last Lab Results', selectedResourceType: 'laboratory', recordIds: lastNRecordIdsGroupedByDay(laboratories, 5), }); const vitalSigns = sortedResources.filter((item) => item.type === 'vital-signs'); updateOrCreateCollection({ id: PREBUILT_COLLECTIONS_LABELS.lastVitalSigns, label: 'Last Vital Signs', selectedResourceType: 'vital-signs', recordIds: lastNRecordIdsGroupedByDay(vitalSigns, 5), }); }); } case actionTypes.ADD_RESOURCE_TO_COLLECTION: { const { resourceIds } = action.payload; return produce(state, (draft) => { resourceIds.forEach((id) => { const { records } = draft[collectionId]; // eslint-disable-line no-param-reassign records[id] = records[id] ?? createNewCollectionRecord(); records[id].saved = true; records[id].dateSaved = new Date(); }); // eslint-disable-next-line no-param-reassign draft[collectionId].lastUpdated = new Date(); }); } case actionTypes.REMOVE_RESOURCE_FROM_COLLECTION: { const { resourceIds } = action.payload; return produce(state, (draft) => { resourceIds.forEach((id) => { const { records } = draft[collectionId]; // eslint-disable-line no-param-reassign records[id] = records[id] ?? {}; records[id].saved = false; records[id].dateSaved = null; // eslint-disable-next-line no-param-reassign draft[collectionId].lastUpdated = new Date(); }); }); } case actionTypes.SELECT_RESOURCE_TYPE: { const { resourceType } = action.payload; return produce(state, (draft) => { // eslint-disable-next-line no-param-reassign draft[collectionId].selectedResourceType = resourceType; }); } case actionTypes.TOGGLE_RESOURCE_TYPE_FILTERS: { const { resourceType } = action.payload; return produce(state, (draft) => { const collection = draft[collectionId]; const { selectedResourceType, resourceTypeFilters } = collection; const filterIsEnabled = resourceTypeFilters[resourceType]; const nextValue = !filterIsEnabled; if (nextValue && !resourceTypeFilters[selectedResourceType]) { // eg: all types were off collection.selectedResourceType = resourceType; } if (!nextValue && selectedResourceType === resourceType) { // toggling off the active type const enabledTypes = TYPES_SORTED_BY_LABEL.filter((type) => resourceTypeFilters[type]); const nextEnabledType = getNextEnabledType(resourceType, enabledTypes); if (nextEnabledType) { collection.selectedResourceType = nextEnabledType; } } collection.resourceTypeFilters[resourceType] = nextValue; // eslint-disable-line no-param-reassign, max-len }); } case actionTypes.UPDATE_DATE_RANGE_FILTER: { const { dateRangeStart, dateRangeEnd } = action.payload; return produce(state, (draft) => { if (dateRangeStart) { // eslint-disable-next-line no-param-reassign draft[collectionId].dateRangeFilter.dateRangeStart = dateRangeStart.toISOString(); } if (dateRangeEnd) { // eslint-disable-next-line no-param-reassign draft[collectionId].dateRangeFilter.dateRangeEnd = dateRangeEnd.toISOString(); } }); } case actionTypes.UPDATE_MARKED_RESOURCES: { const { subType, resourceIdsMap } = action.payload; return produce(state, (draft) => { const collection = draft[collectionId]; const { records } = collection; const deFocus = (!subType || subType !== collection.focusedSubtype); collection.focusedSubtype = subType; if (deFocus) { Object.values(records).forEach((attributes) => { const prevValue = attributes.highlight; attributes.highlight = (prevValue === FOCUSED ? MARKED : prevValue); // eslint-disable-line max-len, no-param-reassign }); } Object.entries(resourceIdsMap) .forEach(([id, next]) => { records[id] = records[id] ?? createNewCollectionRecord(); const { highlight: prev } = records[id]; records[id].highlight = ((prev === MARKED && next === FOCUSED) ? FOCUSED : next); }); }); } case actionTypes.CLEAR_MARKED_RESOURCES: { return produce(state, (draft) => { Object.values(draft[collectionId].records).forEach((attributes) => { attributes.highlight = UNMARKED; // eslint-disable-line no-param-reassign }); draft[collectionId].showMarkedOnly = false; // eslint-disable-line no-param-reassign }); } case actionTypes.CREATE_COLLECTION: { const newCollection = createCollection({ label: action.payload, }); return { ...state, [newCollection.id]: newCollection, }; } case actionTypes.DELETE_COLLECTION: { const newState = { ...state }; delete newState[action.payload.collectionId]; return newState; } case actionTypes.RENAME_COLLECTION: { const updatedCollection = { ...state[action.payload.collectionId] }; updatedCollection.label = action.payload.collectionName; return { ...state, [action.payload.collectionId]: updatedCollection }; } case actionTypes.CLEAR_COLLECTION: { return produce(state, (draft) => { Object.values(draft[collectionId].records).forEach((attributes) => { attributes.saved = false; // eslint-disable-line no-param-reassign attributes.dateSaved = null; // eslint-disable-line no-param-reassign }); // eslint-disable-next-line no-param-reassign draft[collectionId].lastUpdated = new Date(); draft[collectionId].showCollectionOnly = false; // eslint-disable-line no-param-reassign }); } case actionTypes.DUPLICATE_COLLECTION: { const { collectionName } = action.payload; const originalCollection = state[collectionId]; const newCollection = clone(originalCollection); newCollection.id = uuidv4(); newCollection.label = collectionName; newCollection.preBuilt = false; return { ...state, [newCollection.id]: newCollection, }; } case actionTypes.TOGGLE_SHOW_COLLECTION_ONLY: { const { showCollectionOnly } = action.payload; return produce(state, (draft) => { // eslint-disable-next-line no-param-reassign draft[collectionId].showCollectionOnly = showCollectionOnly; }); } case actionTypes.TOGGLE_SHOW_MARKED_ONLY: { const { showMarkedOnly } = action.payload; return produce(state, (draft) => { // eslint-disable-next-line no-param-reassign draft[collectionId].showMarkedOnly = showMarkedOnly; }); } case actionTypes.TOGGLE_SORTING_STATE: { const { sortField } = action.payload; return produce(state, (draft) => { if (state[collectionId].detailsPanelSortingState.activeSortField === sortField) { const prevDir = state[collectionId].detailsPanelSortingState.sortDirections[sortField]; // eslint-disable-next-line no-param-reassign draft[collectionId] .detailsPanelSortingState.sortDirections[sortField] = ( (prevDir === SORT_ASC) ? SORT_DESC : SORT_ASC ); } // eslint-disable-next-line no-param-reassign draft[collectionId] .detailsPanelSortingState.activeSortField = sortField; }); } case actionTypes.CREATE_RECORD_NOTE: { const { resourceId, text } = action.payload; return produce(state, (draft) => { // eslint-disable-next-line no-param-reassign draft[collectionId].records[resourceId] = draft[collectionId].records[resourceId] || {}; // eslint-disable-next-line no-param-reassign draft[collectionId].records[resourceId].notes = ( draft[collectionId].records[resourceId].notes || {} ); const newNote = createNote(text); // eslint-disable-next-line no-param-reassign draft[collectionId].records[resourceId].notes[newNote.id] = newNote; // eslint-disable-next-line no-param-reassign draft[collectionId].lastUpdated = new Date(); }); } case actionTypes.DELETE_RECORD_NOTE: { const { resourceId, noteId } = action.payload; return produce(state, (draft) => { // eslint-disable-next-line no-param-reassign delete draft[collectionId].records[resourceId].notes[noteId]; // eslint-disable-next-line no-param-reassign draft[collectionId].lastUpdated = new Date(); }); } case actionTypes.EDIT_RECORD_NOTE: { const { resourceId, noteId, text } = action.payload; return produce(state, (draft) => { // eslint-disable-next-line no-param-reassign draft[collectionId].records[resourceId].notes[noteId].text = text; // eslint-disable-next-line no-param-reassign draft[collectionId].records[resourceId].notes[noteId].dateEdited = new Date(); // eslint-disable-next-line no-param-reassign draft[collectionId].lastUpdated = new Date(); }); } case actionTypes.CREATE_COLLECTION_NOTE: { const { text } = action.payload; const newNote = createNote(text); return produce(state, (draft) => { // eslint-disable-next-line no-param-reassign draft[collectionId].notes[newNote.id] = newNote; // eslint-disable-next-line no-param-reassign draft[collectionId].lastUpdated = new Date(); }); } case actionTypes.DELETE_COLLECTION_NOTE: { const { noteId } = action.payload; return produce(state, (draft) => { // eslint-disable-next-line no-param-reassign delete draft[collectionId].notes[noteId]; // eslint-disable-next-line no-param-reassign draft[collectionId].lastUpdated = new Date(); }); } case actionTypes.EDIT_COLLECTION_NOTE: { const { noteId, text } = action.payload; return produce(state, (draft) => { // eslint-disable-next-line no-param-reassign draft[collectionId].notes[noteId].text = text; // eslint-disable-next-line no-param-reassign draft[collectionId].notes[noteId].dateEdited = new Date(); // eslint-disable-next-line no-param-reassign draft[collectionId].lastUpdated = new Date(); }); } case actionTypes.EDIT_COLLECTION_DETAILS: { const { purpose, tags, current, urgent, } = action.payload; return produce(state, (draft) => { draft[collectionId] = { // eslint-disable-line no-param-reassign ...draft[collectionId], purpose, tags, current, urgent, lastUpdated: new Date(), }; }); } default: return state; } }; export const activeCollectionIdReducer = (state = null, action) => { switch (action.type) { case actionTypes.CLEAR_PATIENT_DATA: { return defaultCollection.id; } case actionTypes.SELECT_COLLECTION: { return action.payload; } case actionTypes.DELETE_COLLECTION: { return null; } default: return state; } }; export const isCreatingNewCollectionReducer = (state = false, action) => { switch (action.type) { case actionTypes.ADDING_NEW_COLLECTION: { return action.payload; } default: return state; } };