SymphonyPlatformSolutions/symphony-ui-toolkit

View on GitHub
packages/components/src/components/time-picker/utils/timeUtils.tsx

Summary

Maintainability
C
7 hrs
Test Coverage
import { format as formatTime, parse as parseTime, isValid } from 'date-fns';

import Time from './Time';
import { DisabledTime, TimePickerOption } from '../interfaces';

export const TIME_REGEXPR = {
  HH_MM_SS_12: /^(0[0-9]|1[0-2]):([0-5][0-9]):?([0-5][0-9])?\s+([AaPp][Mm])?$/,
  HH_MM_SS_24: /^(0[0-9]|1[0-9]|2[0-3]):([0-5][0-9]):?([0-5][0-9])?$/,
};

export const ISO_TIME_SEPARATOR = ':';
export const REGEXP_TIME_SEPARATOR = '[:\\s]';

export enum TIME_FORMAT {
  HH_MM_A_12 = 'hh:mm a',
  HH_MM_SS_A_12 = 'hh:mm:ss a',
  HH_MM_24 = 'HH:mm',
  HH_MM_SS_24 = 'HH:mm:ss',
}

export enum FIELD {
  HOURS = 'hours',
  MINUTES = 'minutes',
  SECONDS = 'seconds',
  AMPM = 'ampm',
}

/**
 * Detects browser's locale 24h time preference
 * It works by checking whether hour output contains a space ('1 AM' or '01')
 */
const isBrowserLocale24h = () =>
  !new Intl.DateTimeFormat(undefined, { hour: 'numeric' })
    .format(0)
    .match(/AM/);

/**
 * Return the position of the first found delimiter (':', ' ') in the string given in parameter
 * Example: '20:00:00' will return 2
 * @param time
 */
export const getDelimiterPosition = (time: string): number => {
  if (time) {
    const regExpr = new RegExp(REGEXP_TIME_SEPARATOR, 'g');
    const match = regExpr.exec(time);
    if (match) {
      // Return 1st match
      return match.index;
    }
  }
  return null;
};

/**
 * Return the position of the last found delimiter (':', ' ') in the string given in parameter
 * Example: '20:00:00' will return 5
 * @param time
 */
export const getLastDelimiterPosition = (time: string): number => {
  if (time) {
    const regExpr = new RegExp(REGEXP_TIME_SEPARATOR, 'g');
    let lastIndex = null;
    let match;
    while ((match = regExpr.exec(time))) {
      lastIndex = match.index;
    }
    return lastIndex;
  }
  return null;
};

/**
 * Returns the position of the next field in the string given in parameter.
 * Example:
 * ('08:00:00', 0, true) => returns {start: 3, end: 5}
 * ('08:00:00', 4, true) => returns {start: 6, end: 8}
 *
 * @param value
 * @param cursorPosition
 * @param searchForward 'true' if the search is forward, 'false' if is backward
 */
export const getNextSelectionIndexes = (
  value: string,
  cursorPosition: number,
  searchForward = true
): { start: number; end: number } => {
  const positions = { start: null, end: null };
  if (searchForward) {
    const start = getDelimiterPosition(value.substring(cursorPosition));
    let end = null;
    if (start !== null) {
      positions.start = cursorPosition + start + 1; // + 1 to take into account the delimiter
      end = getDelimiterPosition(value.substring(positions.start));
    } else {
      // 'start' position not found
      return null;
    }
    if (end !== null) {
      positions.end = positions.start + end;
    }
    if ((positions.start && !positions.end) || positions.end > value.length) {
      positions.end = value.length;
    }
  } else {
    const end = getLastDelimiterPosition(value.substring(0, cursorPosition));
    let start = null;
    if (end !== null) {
      positions.end = end;
      start = getLastDelimiterPosition(value.substring(0, positions.end));
    } else {
      // 'end' position not found
      return null;
    }
    if (start !== null) {
      positions.start = start + 1; // + 1 to take into account the delimiter
    }
    if (!positions.start && positions.end) {
      positions.start = 0;
    }
  }
  return positions;
};

/**
 * Return The user format depending on 12/24 hours
 */
export const getUserFormat = (): string => {
  return isBrowserLocale24h()
    ? TIME_FORMAT.HH_MM_SS_24
    : TIME_FORMAT.HH_MM_SS_A_12;
};

/**
 * Return true if the time is valid to the format given in parameter.
 *
 * @param time
 * @param format
 */
export const isTimeValid = (time: string, format: string = null): boolean => {
  if (!time) {
    return false;
  }

  if (format === null) {
    format = isBrowserLocale24h
      ? TIME_FORMAT.HH_MM_SS_24
      : TIME_FORMAT.HH_MM_A_12;
  }

  const date = parseTime(time, format, new Date());

  // If parsing failed, Invalid Date will be returned.
  // Invalid Date is a Date, whose time value is NaN.
  // Time value of Date: http://es5.github.io/#x15.9.1.1
  return !isNaN(date.getTime());
};

/**
 * Format time in ISO time format 'HH:MM:SS' on 24 hours
 * @param time Time {hours, minutes, seconds}
 */
export const formatTimeISO = (time: Time): string => {
  if (
    !time ||
    time.hours === '' ||
    time.minutes === '' ||
    isNaN(Number(time.hours)) ||
    isNaN(Number(time.minutes))
  ) {
    return null;
  }

  return (
    time.hours.toString().padStart(2, '0') +
    ISO_TIME_SEPARATOR +
    time.minutes.toString().padStart(2, '0') +
    ISO_TIME_SEPARATOR +
    time.seconds.toString().padStart(2, '0')
  );
};

/**
 * Convert ISO time to seconds
 *
 * @param time A string compliant to the format HH:MM:ss (on 24 hours)
 */
export const formatISOTimeToSeconds = (time: string): number => {
  const date = parseTime(time, TIME_FORMAT.HH_MM_SS_24, 0);
  if (isNaN(date.getTime())) {
    //Invalid Time
    return null;
  }
  return date.getHours() * 60 * 60 + date.getMinutes() * 60 + date.getSeconds();
};

/**
 * Return the number in a string with 2 digits
 * Examples: '01', '23', '00', ...
 * @param number
 */
export const getNumberOn2Digits = (number: number): string =>
  number.toLocaleString(undefined, { minimumIntegerDigits: 2 });

/**
 * Return the options to use in the DropDown menu
 *
 * @param format Format used to display the time
 * @param min Minimum value
 * @param max Maximum value
 * @param step Step in seconds
 */
export const getOptions = (
  format: string,
  min: number,
  max: number,
  step: number
): Array<TimePickerOption> => {
  const options: Array<TimePickerOption> = [];
  for (
    let currentTime = min, index = 0;
    currentTime <= max;
    currentTime += step, index++
  ) {
    const time = getTimeFromSeconds(currentTime);
    options.push({
      label: getFormattedTime(time, format),
      value: formatTimeISO(time),
      data: {
        index, // Save the index of the Option, for easy access to the previous/next option if needed
        time, // hours, minutes, seconds
      },
    });
  }
  return options;
};

/**
 * Return `true` if the given time matches to a disabled time, false otherwise
 * @param time
 * @param disabledTimes Example '20:40:00' or ['20:40:00', '12:00:00', {from:'10:00:00', to:'11:00:00'}]
 */
export const isTimeDisabled = (
  time: Time,
  disabledTimes: DisabledTime | Array<DisabledTime>
): boolean => {
  if (!time) {
    return true;
  }
  if (
    !disabledTimes ||
    (Array.isArray(disabledTimes) && disabledTimes.length === 0)
  ) {
    return false;
  }
  let disabledTimesAsArray;
  if (Array.isArray(disabledTimes)) {
    disabledTimesAsArray = disabledTimes;
  } else {
    disabledTimesAsArray = [disabledTimes];
  }

  return disabledTimesAsArray.some((disabledTime) => {
    return (
      matchExactTime(time, disabledTime) || matchTimeInRange(time, disabledTime)
    );
  });
};

/**
 * Iterates over the option and return true is the time matches with one of the options
 * @param time
 * @param options
 */
export const isTimeProposed = (
  time: Time,
  options: Array<TimePickerOption>
): boolean =>
  time &&
  options &&
  options.some(
    (option) => time.isEqual(option?.data?.time)
  );

/**
 * Return true if the time is matching with the hours/minutes/seconds and not appears in the disabledTimes
 * @param time
 * @param hours
 * @param minutes
 * @param seconds
 * @param disabledTimes
 */
export const isTimeSelected = (
  time: Time,
  hours: string,
  minutes: string,
  seconds: string,
  disabledTimes: DisabledTime | Array<DisabledTime>
): boolean =>
  time &&
  time.hours === hours &&
  time.minutes === minutes &&
  time.seconds === seconds &&
  !isTimeDisabled(time, disabledTimes);

/**
 * Get ISO time in an object {hours, minutes, seconds} from a given local time and format
 * @param time
 * @param format (optional) Use HH:mm:ss per default (on 24 hours)
 */
export const getISOTimeFromLocalTime = (
  time: string,
  format: string = TIME_FORMAT.HH_MM_SS_24
): Time => {
  if (!time || !format || !getTimeFromString(time)) {
    return null;
  }

  try {
    const date = parseTime(time, format, 0);

    // If parsing failed, Invalid Date will be returned.
    // Invalid Date is a Date, whose time value is NaN.
    // Time value of Date: http://es5.github.io/#x15.9.1.1
    if (isNaN(date.getTime())) {
      return null;
    }

    return new Time(
      getNumberOn2Digits(date.getHours()),
      getNumberOn2Digits(date.getMinutes()),
      getNumberOn2Digits(date.getSeconds())
    );
  } catch (error) {
    if (error instanceof RangeError) {
      console.error(error);
      return null;
    }
    // Re-throw the error not handled
    throw error;
  }
};

/**
 * Return the time formatted with the format if it's provided in parameter, else it will use the locale settings of the user
 * @param time Time with {hours, seconds, minutes}
 * @param format
 */
export const getFormattedTime = (time: Time, format: string = null): string => {
  if (!time) {
    // Time null or undefined
    return null;
  }
  const date = new Date();
  date.setHours(
    parseInt(time.hours, 10),
    parseInt(time.minutes, 10),
    parseInt(time.seconds, 10)
  );

  if (!isValid(date)) {
    // Not valid
    return null;
  }

  if (!format) {
    // Return time formatted with locale time (Example: 08:50 AM or 14:55:00 ...)
    return date.toLocaleTimeString();
  }
  // Format time
  return formatTime(date, format);
};

/**
 * Split a time given only in seconds into { hours, minutes, seconds } on 24 hours format
 *
 * @param time In seconds
 * @return Time { hours, minutes, seconds }
 */
export const getTimeFromSeconds = (time: number): Time => {
  const hours = Math.floor(time / 60 / 60);
  const minutes = Math.floor(time / 60) - hours * 60;
  const seconds = time % 60;
  return new Time(
    getNumberOn2Digits(hours),
    getNumberOn2Digits(minutes),
    getNumberOn2Digits(seconds)
  );
};

/**
 * Returns all sorted values used in the options
 * Example:
 * {
 *   hours: ['02', '05', '06', '11'],
 *   minutes: ['00', '15', '30', '45'],
 *   seconds: ['00', '30'],
 * }
 * @param options
 * @param disabledTimes
 */
export const getSteps = (
  options: Array<TimePickerOption>,
  disabledTimes: DisabledTime | Array<DisabledTime>
) => {
  const hoursValues = new Set<string>();
  const minutesValues = new Set<string>();
  const secondsValues = new Set<string>();

  options.forEach((option) => {
    if (!isTimeDisabled(option?.data?.time, disabledTimes)) {
      const time = getTimeFromString(option.label);
      if (time) {
        hoursValues.add(time.hours);
        minutesValues.add(time.minutes);
        secondsValues.add(time.seconds);
      }
    }
  });

  return {
    hours: [...hoursValues].sort(),
    minutes: [...minutesValues].sort(),
    seconds: [...secondsValues].sort(),
  };
};

/**
 * Parse the string and return an object {hours, minutes, seconds, ampm}
 *
 * @param inputTime string (Example: '05:20:10 am', '07:30 AM', '06:00:00 PM', '18:20', '18:20:00'
 * @return {hours: '...', minutes:'...', seconds:'...', ampm: 'AM'}
 * Examples:
 *   {hours: '05', minutes: '20', seconds: '10', ampm: 'am'}
 *   {hours: '18', minutes: '20'}
 */
export const getTimeFromString = (inputTime: string): Time => {
  if (!inputTime) {
    return null;
  }

  const ampmRegExpr = /[AaPp][Mm]/;
  let timeRegExpr = null;
  if (inputTime.match(ampmRegExpr)) {
    // 12 hours format
    timeRegExpr = TIME_REGEXPR.HH_MM_SS_12;
  } else {
    // 24 hours format
    timeRegExpr = TIME_REGEXPR.HH_MM_SS_24;
  }

  const result = inputTime.match(timeRegExpr);
  if (!result) {
    return null;
  }
  const [, hours, minutes, seconds, ampm] = result;

  // Return an object {hours, minutes, seconds, ampm}
  return new Time(hours, minutes, seconds, ampm);
};

/**
 * Return true if the time is equal to the time in the matcher
 * @param time Time {hours, minutes, seconds}
 * @param matcher Object {time: 'HH:mm:ss'}
 */
export function matchExactTime(time: Time, matcher): boolean {
  if (time === null || !('time' in matcher)) return false;
  return formatTimeISO(time) === matcher.time;
}

/**
 * Returns true if the range contains the time
 * @param time Time {hours, minutes, seconds}
 * @param matcher Object {from: 'HH:mm:ss', to: 'HH:mm:ss'}
 */
export function matchTimeInRange(time: Time, matcher): boolean {
  if (time === null || !('from' in matcher) || !('to' in matcher)) return false;
  return (
    matcher.from <= formatTimeISO(time) && formatTimeISO(time) <= matcher.to
  );
}