department-of-veterans-affairs/vets-website

View on GitHub
src/applications/check-in/utils/appointment/index.js

Summary

Maintainability
A
3 hrs
Test Coverage
import React from 'react';
import { parseISO, startOfDay } from 'date-fns';
import { format, utcToZonedTime } from 'date-fns-tz';
import { ELIGIBILITY } from './eligibility';
import { VISTA_CHECK_IN_STATUS_IENS } from '../appConstants';

/**
 * @typedef {Object} Appointment
 * @property {string} facility
 * @property {string} clinicPhoneNumber
 * @property {string} clinicFriendlyName
 * @property {string} clinicName
 * @property {string} appointmentIen,
 * @property {Date} startTime,
 * @property {string} eligibility,
 * @property {Date} checkInWindowStart,
 * @property {Date} checkInWindowEnd,
 * @property {string} checkedInTime,
 * @property {string} appointmentId,
 */

/**
 * @param {Array<Appointment>} appointments
 * @param {Appointment} currentAppointment
 *
 * @returns {boolean}
 */
const hasMoreAppointmentsToCheckInto = (appointments, currentAppointment) => {
  return (
    appointments
      .filter(f => f.appointmentIen !== currentAppointment?.appointmentIen)
      .filter(f => f.eligibility === ELIGIBILITY.ELIGIBLE).length > 0
  );
};

/**
 * Check if any appointment was canceled but not every.
 *
 * @param {Array<Appointment>} appointments
 *
 * @returns {boolean}
 */
const appointmentWasCanceled = appointments => {
  const statusIsCanceled = appointment =>
    appointment.status?.startsWith('CANCELLED');

  return (
    Array.isArray(appointments) &&
    appointments.length > 0 &&
    appointments.some(statusIsCanceled) &&
    !appointments.every(statusIsCanceled)
  );
};

/**
 * Check if every appointment was canceled.
 *
 * @param {Array<Appointment>} appointments
 *
 * @returns {boolean}
 */
const allAppointmentsCanceled = appointments => {
  const statusIsCanceled = appointment =>
    appointment.status?.startsWith('CANCELLED');

  return (
    Array.isArray(appointments) &&
    appointments.length > 0 &&
    appointments.every(statusIsCanceled)
  );
};

/**
 * Return the first cancelled appointment.
 *
 * @param {Array<Appointment>} appointments
 *
 */
const getFirstCanceledAppointment = appointments => {
  const statusIsCanceled = appointment =>
    appointment.status?.startsWith('CANCELLED');

  return appointments.find(statusIsCanceled);
};

/**
 * Get the interval from now until the end of the next check-in window.
 *
 * @param {Array<Appointment>} appointments
 *
 * @returns {number} ms until the end of the next check-in window. (0 if no appointments are eligible for check-in)
 */
const intervalUntilNextAppointmentIneligibleForCheckin = appointments => {
  let interval = 0;

  const eligibleAppointments = appointments.filter(
    appointment => appointment.eligibility === ELIGIBILITY.ELIGIBLE,
  );

  let checkInWindowEnds = eligibleAppointments.map(
    appointment => appointment.checkInWindowEnd,
  );

  checkInWindowEnds = checkInWindowEnds.filter(
    checkInWindowEnd => parseISO(checkInWindowEnd) > Date.now(),
  );

  checkInWindowEnds.sort((a, b) => {
    return parseISO(a) > parseISO(b);
  });

  if (checkInWindowEnds[0]) {
    interval = Math.round(parseISO(checkInWindowEnds[0]) - Date.now());
  }

  return interval;
};

/**
 * Check if all appointments have completed pre-check-in.
 *
 * @param {Array<Appointment>} appointments
 */
const preCheckinAlreadyCompleted = appointments => {
  const isPreCheckinCompleteStep = checkInStep =>
    checkInStep.ien === VISTA_CHECK_IN_STATUS_IENS.PRE_CHECK_IN_COMPLETE;

  const preCheckinCompleted = appointment =>
    appointment.checkInSteps?.length &&
    appointment.checkInSteps.some(isPreCheckinCompleteStep);

  return (
    Array.isArray(appointments) &&
    appointments.length > 0 &&
    appointments.every(preCheckinCompleted)
  );
};

/**
 * Determine whether the physical location should be displayed for the given appointment.
 *
 * @param {Appointment} appointment
 * @returns {boolean}
 */
const locationShouldBeDisplayed = appointment => {
  const notEmpty = location => {
    return typeof location === 'string' && location.length > 0;
  };

  return appointment.kind === 'clinic' && notEmpty(appointment.clinicLocation);
};

/**
 * @param {Array<Appointment>} appointments
 */
const sortAppointmentsByStartTime = appointments => {
  return appointments
    ? [
        ...appointments.sort((first, second) => {
          const f = new Date(first.startTime);
          const s = new Date(second.startTime);
          return new Date(f) - new Date(s);
        }),
      ]
    : [];
};

/**
 * @param {Array<Appointment>} appointments
 */

function organizeAppointmentsByYearMonthDay(appointments) {
  const organizedData = [];

  // First sort the appointments by start time then organize them by yearmonth and day
  const sortedAppointments = sortAppointmentsByStartTime(appointments);

  for (const appointment of sortedAppointments) {
    const dateObj = new Date(appointment.startTime);
    const monthYearKey = `${dateObj.getFullYear()}-${Number(
      dateObj.getMonth(),
    ) + 1}`;
    const dayKey = `${dateObj.getDay()}-${dateObj.getDate()}`;

    let monthObj = organizedData.find(
      item => item.monthYearKey === monthYearKey,
    );

    if (!monthObj) {
      monthObj = {
        monthYearKey,
        days: [],
        firstAppointmentStartTime: appointment.startTime,
      };
      organizedData.push(monthObj);
    }

    let dayObj = monthObj.days.find(item => item.dayKey === dayKey);

    if (!dayObj) {
      dayObj = {
        dayKey,
        appointments: [],
        firstAppointmentStartTime: appointment.startTime,
      };
      monthObj.days.push(dayObj);
    }

    dayObj.appointments.push(appointment);
  }

  return organizedData;
}

const removeTimeZone = payload => {
  // Grabbing the appointment payload and stripping out timezone here.
  // Chip should be handling this but currently isn't, this code may be refactored out.
  const updatedPayload = { ...payload };
  // These fields have a potential to include a time stamp.
  const timeFields = ['checkedInTime', 'startTime'];

  const updatedAppointments = updatedPayload.appointments.map(appointment => {
    const updatedAppointment = { ...appointment };
    // If field exists in object we will replace the TZ part of the string.
    timeFields.forEach(field => {
      if (field in updatedAppointment) {
        updatedAppointment[field] = updatedAppointment[field].replace(
          /(?=\.).*/,
          '',
        );
      }
    });
    return updatedAppointment;
  });

  updatedPayload.appointments = updatedAppointments;

  return updatedPayload;
};

const preCheckinExpired = appointments => {
  return !Object.values(appointments).some(appt => {
    const today = new Date();
    const preCheckInExpiry = startOfDay(new Date(appt.startTime));
    return today.getTime() < preCheckInExpiry.getTime();
  });
};

const hasPhoneAppointments = appointments => {
  return Object.values(appointments).some(appt => {
    return appt?.kind === 'phone';
  });
};

/**
 * Render the appointment type icon
 *
 * @param {Appointment} appointment
 * @returns {Node}
 */

const appointmentIcon = appointment => {
  let iconName;
  switch (appointment?.kind) {
    case 'clinic':
    case 'cvt':
      iconName = 'location_city';
      break;
    case 'vvc':
      iconName = 'videocam';
      break;
    default:
      iconName = 'phone';
      break;
  }

  return <va-icon icon={iconName} size={3} data-testid="appointment-icon" />;
};

/**
 * Return the name to use for appointment clinic.
 *
 * @param {Appointment} appointment
 * @returns {string}
 */

const clinicName = appointment => {
  return appointment.clinicFriendlyName
    ? appointment.clinicFriendlyName
    : appointment.clinicName;
};

/**
 * Return a unique ID of ien and station for vista appointments.
 *
 * @param {Appointment} appointment
 * @returns {string}
 */

const getAppointmentId = appointment => {
  if (appointment.id) {
    return `${appointment.id}-${appointment.stationNo}`;
  }
  return `${appointment.appointmentIen}-${appointment.stationNo}`;
};

/**
 * Find appointment by ID.
 *
 * @param {appointmentId} appointmentId
 * @param {Array<Appointment>} appointments
 * @returns {object}
 */

const findAppointment = (appointmentId, appointments) => {
  const appointmentIdParts = appointmentId.split('-');
  return appointments.find(
    appointmentItem =>
      String(appointmentItem.appointmentIen) ===
        String(appointmentIdParts[0]) &&
      String(appointmentItem.stationNo) === String(appointmentIdParts[1]),
  );
};

/**
 * Find upcoming appointment by ID.
 *
 * @param {string} appointmentId
 * @param {Array<Appointment>} appointments
 * @returns {object}
 */
const findUpcomingAppointment = (appointmentId, appointments) => {
  const appointementIdParts = appointmentId.split('-');
  return appointments.find(
    appointmentItem =>
      appointmentItem.id === appointementIdParts[0] &&
      appointmentItem.stationNo === appointementIdParts[1],
  );
};

/**
 * Determine if the appoinents have multiple facilities.
 *
 * @param {Array<Appointment>} appointments
 * @returns {bool}
 */

const hasMultipleFacilities = appointments => {
  const uniqueFacilites = [
    ...new Map(appointments.map(appt => [appt.stationNo, appt])).values(),
  ];
  return uniqueFacilites.length > 1;
};

/**
 * Return unique facilities as an array
 * @param {Array<Appointment>} appointments
 * @returns {Array}
 */

const getUniqueFacilies = appointments => {
  return [...new Set(appointments.map(appt => appt.facility))];
};

/**
 * Return adjusted ISO timestring
 * @param {string} time
 * @param {string} timezone
 * @param {string} isoFormat
 * @returns {string}
 */

const utcToFacilityTimeZone = (
  time,
  timezone,
  isoFormat = "yyyy-LL-dd'T'HH:mm:ss.SSSxxx",
) => {
  return format(utcToZonedTime(time, timezone), isoFormat, {
    timeZone: timezone,
  });
};

/**
 * Return label for appointment
 * @param {object} appointment
 * @returns {string}
 */

const getApptLabel = appointment => {
  const time = utcToFacilityTimeZone(
    appointment.startTime,
    appointment.timezone,
    'h:mm aaaa',
  );
  const label = appointment.clinicFriendlyName
    ? appointment.clinicFriendlyName
    : appointment.clinicStopCodeName;
  return `${time}${label ? ` ${label}` : ''}`;
};

/**
 * Determine if there are multiple checkinable appointments.
 *
 * @param {appointments} appointments
 * @returns {boolean}
 */

const getCheckinableAppointments = appointments => {
  return appointments.filter(a => a.eligibility === ELIGIBILITY.ELIGIBLE);
};

/**
 * Convert the appointments from the API to the format needed for the UI.
 * @param {Array} appointments
 * @returns {Array}
 */
const convertAppointments = appointments => {
  return appointments.map(appointment => ({
    id: appointment.id,
    facility: appointment.attributes.facilityName,
    clinicPhoneNumber: null,
    clinicFriendlyName: appointment.attributes.clinicFriendlyName,
    clinicName: appointment.attributes.clinic,
    clinicStopCodeName: null,
    clinicLocation: appointment.attributes.clinicPhysicalLocation,
    doctorName: null,
    appointmentIen: null,
    startTime: appointment.attributes.start,
    stationNo: appointment.attributes.locationId,
    eligibility: null,
    kind: appointment.attributes.kind,
    clinicIen: null,
    checkInWindowStart: null,
    checkInWindowEnd: null,
    checkInSteps: null,
    checkedInTime: null,
    status: appointment.attributes.status,
    facilityAddress: null,
  }));
};

export {
  appointmentWasCanceled,
  allAppointmentsCanceled,
  getFirstCanceledAppointment,
  hasMoreAppointmentsToCheckInto,
  intervalUntilNextAppointmentIneligibleForCheckin,
  locationShouldBeDisplayed,
  sortAppointmentsByStartTime,
  organizeAppointmentsByYearMonthDay,
  preCheckinAlreadyCompleted,
  removeTimeZone,
  preCheckinExpired,
  hasPhoneAppointments,
  appointmentIcon,
  clinicName,
  getAppointmentId,
  findAppointment,
  findUpcomingAppointment,
  hasMultipleFacilities,
  getUniqueFacilies,
  utcToFacilityTimeZone,
  getApptLabel,
  getCheckinableAppointments,
  convertAppointments,
};