opf/openproject

View on GitHub
frontend/src/app/shared/helpers/chronic_duration.js

Summary

Maintainability
D
2 days
Test Coverage
/*
 * This code is based on code from
 * https://gitlab.com/gitlab-org/gitlab-chronic-duration and is
 * distributed under the following license:
 *
 * MIT License
 *
 * Copyright (c) Henry Poydar
 *
 * Permission is hereby granted, free of charge, to any person
 * obtaining a copy of this software and associated documentation
 * files (the "Software"), to deal in the Software without
 * restriction, including without limitation the rights to use,
 * copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the
 * Software is furnished to do so, subject to the following
 * conditions:
 *
 * The above copyright notice and this permission notice shall be
 * included in all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
 * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
 * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
 * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
 * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
 * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
 * OTHER DEALINGS IN THE SOFTWARE.
 */

/*
 * NOTE:
 * Changes to this file should be kept in sync with
 * lib/chronic_duration.rb.
 */

/* eslint-disable */
export class DurationParseError extends Error {
}

// On average, there's a little over 4 weeks in month.
const FULL_WEEKS_PER_MONTH = 4;

// 365.25 days in a year.
const SECONDS_PER_YEAR = 31557600;

const HOURS_PER_DAY = 24;
const DAYS_PER_MONTH = 30;

const FLOAT_MATCHER = /[0-9]*\.?[0-9]+/g;
const DURATION_UNITS_LIST = ['seconds', 'minutes', 'hours', 'days', 'weeks', 'months', 'years'];

const MAPPINGS = {
  seconds: 'seconds',
  second: 'seconds',
  secs: 'seconds',
  sec: 'seconds',
  s: 'seconds',
  minutes: 'minutes',
  minute: 'minutes',
  mins: 'minutes',
  min: 'minutes',
  m: 'minutes',
  hours: 'hours',
  hour: 'hours',
  hrs: 'hours',
  hr: 'hours',
  h: 'hours',
  days: 'days',
  day: 'days',
  dy: 'days',
  d: 'days',
  weeks: 'weeks',
  week: 'weeks',
  wks: 'weeks',
  wk: 'weeks',
  w: 'weeks',
  months: 'months',
  mo: 'months',
  mos: 'months',
  month: 'months',
  years: 'years',
  year: 'years',
  yrs: 'years',
  yr: 'years',
  y: 'years',
};

const JOIN_WORDS = ['and', 'with', 'plus'];

function convertToNumber(string) {
  const f = parseFloat(string);
  return f % 1 > 0 ? f : parseInt(string, 10);
}

function durationUnitsSecondsMultiplier(unit, opts) {
  if (!DURATION_UNITS_LIST.includes(unit)) {
    return 0;
  }

  const hoursPerDay = opts.hoursPerDay || HOURS_PER_DAY;
  const daysPerMonth = opts.daysPerMonth || DAYS_PER_MONTH;
  const daysPerWeek = Math.trunc(daysPerMonth / FULL_WEEKS_PER_MONTH);

  switch (unit) {
    case 'years':
      return SECONDS_PER_YEAR;
    case 'months':
      return 3600 * hoursPerDay * daysPerMonth;
    case 'weeks':
      return 3600 * hoursPerDay * daysPerWeek;
    case 'days':
      return 3600 * hoursPerDay;
    case 'hours':
      return 3600;
    case 'minutes':
      return 60;
    case 'seconds':
      return 1;
    default:
      return 0;
  }
}

function calculateFromWords(string, opts) {
  let val = 0;
  const words = string.split(' ');
  words.forEach((v, k) => {
    if (v === '') {
      return;
    }
    if (v.search(FLOAT_MATCHER) >= 0) {
      val +=
        convertToNumber(v) *
        durationUnitsSecondsMultiplier(
          words[parseInt(k, 10) + 1] || opts.defaultUnit || 'seconds',
          opts,
        );
    }
  });
  return val;
}

// Parse 3:41:59 and return 3 hours 41 minutes 59 seconds
function filterByType(string) {
  const chronoUnitsList = DURATION_UNITS_LIST.filter((v) => v !== 'weeks');
  if (
    string
      .replace(/ +/g, '')
      .search(RegExp(`${FLOAT_MATCHER.source}(:${FLOAT_MATCHER.source})+`, 'g')) >= 0
  ) {
    const res = [];
    string
      .replace(/ +/g, '')
      .split(':')
      .reverse()
      .forEach((v, k) => {
        if (!chronoUnitsList[k]) {
          return;
        }
        res.push(`${v} ${chronoUnitsList[k]}`);
      });
    return res.reverse().join(' ');
  }
  return string;
}

// Get rid of unknown words and map found
// words to defined time units
function filterThroughWhiteList(string, opts) {
  const res = [];
  string.split(' ').forEach((word) => {
    if (word === '') {
      return;
    }
    if (word.search(FLOAT_MATCHER) >= 0) {
      res.push(word.trim());
      return;
    }
    const strippedWord = word.trim().replace(/^,/g, '').replace(/,$/g, '');
    if (MAPPINGS[strippedWord] !== undefined) {
      res.push(MAPPINGS[strippedWord]);
    } else if (!JOIN_WORDS.includes(strippedWord) && opts.raiseExceptions) {
      throw new DurationParseError(
        `An invalid word ${JSON.stringify(word)} was used in the string to be parsed.`,
      );
    }
  });
  // add '1' at front if string starts with something recognizable but not with a number, like 'day' or 'minute 30sec'
  if (res.length > 0 && MAPPINGS[res[0]]) {
    res.splice(0, 0, 1);
  }
  return res.join(' ');
}

function cleanup(string, opts) {
  let res = string.toLowerCase();
  res = filterByType(res);
  res = res
    .replace(FLOAT_MATCHER, (n) => ` ${n} `)
    .replace(/ +/g, ' ')
    .trim();
  return filterThroughWhiteList(res, opts);
}

function humanizeTimeUnit(number, unit, pluralize, keepZero) {
  if (number === '0' && !keepZero) {
    return null;
  }
  if (unit === undefined) {
    return null;
  }
  let res = number + unit;
  // A poor man's pluralizer
  if (number !== '1' && pluralize) {
    res += 's';
  }
  return res;
}

// Given a string representation of elapsed time,
// return an integer (or float, if fractions of a
// second are input)
export function parseChronicDuration(string, opts = {}) {
  const result = calculateFromWords(cleanup(string, opts), opts);
  return !opts.keepZero && result === 0 ? null : result;
}

// Given an integer and an optional format,
// returns a formatted string representing elapsed time
export function outputChronicDuration(seconds, opts = {}) {
  const units = {
    years: 0,
    months: 0,
    weeks: 0,
    days: 0,
    hours: 0,
    minutes: 0,
    seconds,
  };

  const hoursPerDay = opts.hoursPerDay || HOURS_PER_DAY;
  const daysPerMonth = opts.daysPerMonth || DAYS_PER_MONTH;
  const daysPerWeek = Math.trunc(daysPerMonth / FULL_WEEKS_PER_MONTH);

  const decimalPlaces =
    seconds % 1 !== 0 ? seconds.toString().split('.').reverse()[0].length : null;

  const minute = 60;
  const hour = 60 * minute;
  const day = hoursPerDay * hour;
  const month = daysPerMonth * day;
  const year = SECONDS_PER_YEAR;

  if (units.seconds >= SECONDS_PER_YEAR && units.seconds % year < units.seconds % month) {
    units.years = Math.trunc(units.seconds / year);
    units.months = Math.trunc((units.seconds % year) / month);
    units.days = Math.trunc(((units.seconds % year) % month) / day);
    units.hours = Math.trunc((((units.seconds % year) % month) % day) / hour);
    units.minutes = Math.trunc(((((units.seconds % year) % month) % day) % hour) / minute);
    units.seconds = Math.trunc(((((units.seconds % year) % month) % day) % hour) % minute);
  } else if (seconds >= 60) {
    units.minutes = Math.trunc(seconds / 60);
    units.seconds %= 60;
    if (units.minutes >= 60) {
      units.hours = Math.trunc(units.minutes / 60);
      units.minutes = Math.trunc(units.minutes % 60);
      if (!opts.limitToHours) {
        if (units.hours >= hoursPerDay) {
          units.days = Math.trunc(units.hours / hoursPerDay);
          units.hours = Math.trunc(units.hours % hoursPerDay);
          if (opts.weeks) {
            if (units.days >= daysPerWeek) {
              units.weeks = Math.trunc(units.days / daysPerWeek);
              units.days = Math.trunc(units.days % daysPerWeek);
              if (units.weeks >= FULL_WEEKS_PER_MONTH) {
                units.months = Math.trunc(units.weeks / FULL_WEEKS_PER_MONTH);
                units.weeks = Math.trunc(units.weeks % FULL_WEEKS_PER_MONTH);
              }
            }
          } else if (units.days >= daysPerMonth) {
            units.months = Math.trunc(units.days / daysPerMonth);
            units.days = Math.trunc(units.days % daysPerMonth);
          }
        }
      }
    }
  }

  let joiner = opts.joiner || ' ';
  let process = null;

  let dividers;
  switch (opts.format) {
    case 'micro':
      dividers = {
        years: 'y',
        months: 'mo',
        weeks: 'w',
        days: 'd',
        hours: 'h',
        minutes: 'm',
        seconds: 's',
      };
      joiner = '';
      break;
    case 'short':
      dividers = {
        years: 'y',
        months: 'mo',
        weeks: 'w',
        days: 'd',
        hours: 'h',
        minutes: 'm',
        seconds: 's',
      };
      break;
    case 'long':
      dividers = {
        /* eslint-disable @gitlab/require-i18n-strings */
        years: ' year',
        months: ' month',
        weeks: ' week',
        days: ' day',
        hours: ' hour',
        minutes: ' minute',
        seconds: ' second',
        /* eslint-enable @gitlab/require-i18n-strings */
        pluralize: true,
      };
      break;
    case 'daysAndHours':
      dividers = {
        // days: 'd',
        hours: 'h',
        keepZero: true,
      };

      units.days += units.weeks * daysPerWeek
      units.weeks = 0
      units.days += units.months * daysPerMonth
      units.months = 0
      units.days += Math.floor(units.years * SECONDS_PER_YEAR / 3600 / 24)
      units.years = 0
      if (units.days > 0) {
        dividers.days = 'd';
      }

      units.hours += (((units.minutes * 60) + units.seconds) / 3600.0)
      units.hours = parseFloat(Math.round(units.hours * 100)) / 100;

      break
    case 'chrono':
      dividers = {
        years: ':',
        months: ':',
        weeks: ':',
        days: ':',
        hours: ':',
        minutes: ':',
        seconds: ':',
        keepZero: true,
      };
      process = (str) => {
        // Pad zeros
        // Get rid of lead off times if they are zero
        // Get rid of lead off zero
        // Get rid of trailing:
        const divider = ':';
        const processed = [];
        str.split(divider).forEach((n) => {
          if (n === '') {
            return;
          }
          // add zeros only if n is an integer
          if (n.search('\\.') >= 0) {
            processed.push(
              parseFloat(n)
                .toFixed(decimalPlaces)
                .padStart(3 + decimalPlaces, '0'),
            );
          } else {
            processed.push(n.padStart(2, '0'));
          }
        });
        return processed
          .join(divider)
          .replace(/^(00:)+/g, '')
          .replace(/^0/g, '')
          .replace(/:$/g, '');
      };
      joiner = '';
      break;
    default:
      dividers = {
        /* eslint-disable @gitlab/require-i18n-strings */
        years: ' yr',
        months: ' mo',
        weeks: ' wk',
        days: ' day',
        hours: ' hr',
        minutes: ' min',
        seconds: ' sec',
        /* eslint-enable @gitlab/require-i18n-strings */
        pluralize: true,
      };
      break;
  }

  let result = [];
  ['years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds'].forEach((t) => {
    if (t === 'weeks' && !opts.weeks) {
      return;
    }
    let num = units[t];
    if (t === 'seconds' && num % 0 !== 0) {
      num = num.toFixed(decimalPlaces);
    } else {
      num = num.toString();
    }
    const keepZero = !dividers.keepZero && t === 'seconds' ? opts.keepZero : dividers.keepZero;
    const humanized = humanizeTimeUnit(num, dividers[t], dividers.pluralize, keepZero);
    if (humanized !== null) {
      result.push(humanized);
    }
  });

  if (opts.units) {
    result = result.slice(0, opts.units);
  }

  result = result.join(joiner);

  if (process) {
    result = process(result);
  }

  return result.length === 0 ? null : result;
}