├── packages ├── sample │ ├── src │ │ ├── index.js │ │ └── index.test.js │ ├── README.md │ ├── package.json │ ├── webpack.config.js │ └── LICENSE.md └── date-parser │ ├── src │ ├── helpers │ │ ├── isValidDate.js │ │ ├── isTimeUnit.js │ │ ├── toPluralLuxonUnit.js │ │ ├── weekdayIdxFromLuxon.js │ │ ├── checkDateStringCharacterLimit.js │ │ ├── luxonFromJSDate.js │ │ ├── pluralize.js │ │ ├── dateStructFromDate.js │ │ ├── chronoDateStructFromMoment.js │ │ ├── getHighestLevelDateUnit.test.js │ │ ├── luxonFromStruct.test.js │ │ ├── getHighestLevelDateUnit.js │ │ ├── momentFromStruct.test.js │ │ ├── luxonFromChronoStruct.js │ │ ├── timezoneRegion.js │ │ ├── momentFromStruct.js │ │ ├── luxonFromStruct.js │ │ ├── getParsedResultBoundaries.js │ │ ├── dateStructFromLuxon.js │ │ ├── splitInputString.js │ │ ├── truncateDateStruct.js │ │ └── startEndOfCustom.js │ ├── errors.js │ ├── initializers │ │ ├── dayjs.js │ │ └── chrono-node.js │ ├── result.test.js │ ├── refiners │ │ ├── ambiguity.js │ │ ├── v3 │ │ │ ├── timezone.js │ │ │ └── implier.js │ │ ├── timezone.js │ │ └── implier.js │ ├── parsers │ │ ├── guards │ │ │ └── ambiguousWeekday.js │ │ ├── year.js │ │ ├── v3 │ │ │ ├── constants.js │ │ │ ├── today.js │ │ │ ├── xAgo.js │ │ │ ├── weekday.js │ │ │ ├── lastX.js │ │ │ └── wrapChronoParser.js │ │ ├── constants.js │ │ ├── today.js │ │ ├── yearMonth.js │ │ ├── xAgo.js │ │ ├── weekday.js │ │ └── lastX.js │ ├── index.js │ ├── options.js │ ├── constants.js │ ├── optionsV3.js │ ├── result.js │ ├── dateParserV1.js │ ├── dateParserV3.js │ ├── dateParserV3.test.js │ └── index.test.js │ ├── cli │ ├── build.js │ ├── commands │ │ └── parse.js │ └── index.js │ ├── webpack.config.js │ ├── package.json │ ├── LICENSE.md │ ├── README.md │ └── CHANGELOG.md ├── lerna.json ├── babel.config.js ├── .editorconfig ├── jest.config.js ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── codeql-analysis.yml ├── .circleci └── config.yml ├── .eslintrc.js ├── package.json ├── .gitignore └── README.md /packages/sample/src/index.js: -------------------------------------------------------------------------------- 1 | export default (name) => `Hello, ${name}`; 2 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | "packages/*" 4 | ], 5 | "npmClient": "yarn", 6 | "version": "independent" 7 | } 8 | -------------------------------------------------------------------------------- /packages/date-parser/src/helpers/isValidDate.js: -------------------------------------------------------------------------------- 1 | // https://stackoverflow.com/a/1353711 2 | export default (date) => { 3 | return date instanceof Date 4 | && !Number.isNaN(Number(date)); 5 | }; 6 | -------------------------------------------------------------------------------- /packages/sample/src/index.test.js: -------------------------------------------------------------------------------- 1 | import sample from './index'; 2 | 3 | describe('sample', () => { 4 | it('works', () => { 5 | expect(sample('Hoang')).toEqual('Hello, Hoang'); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /packages/date-parser/src/helpers/isTimeUnit.js: -------------------------------------------------------------------------------- 1 | import { DATE_UNIT_LEVELS } from '../constants'; 2 | 3 | export default (dateUnit) => { 4 | return DATE_UNIT_LEVELS[dateUnit] > DATE_UNIT_LEVELS.day; 5 | }; 6 | -------------------------------------------------------------------------------- /packages/date-parser/src/helpers/toPluralLuxonUnit.js: -------------------------------------------------------------------------------- 1 | const toPluralLuxonUnit = (unit) => { 2 | if (unit[unit.length - 1] === 's') return unit; 3 | return `${unit}s`; 4 | }; 5 | 6 | export default toPluralLuxonUnit; 7 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: { 7 | node: 'current', 8 | }, 9 | }, 10 | ], 11 | ], 12 | }; 13 | -------------------------------------------------------------------------------- /packages/sample/README.md: -------------------------------------------------------------------------------- 1 | # `@holistics/sample` 2 | 3 | > Sample package 4 | 5 | ## Usage 6 | 7 | ```javascript 8 | import sample from '@holistics/sample' 9 | 10 | console.log(sample('John')) // 'Hello, John' 11 | ``` 12 | -------------------------------------------------------------------------------- /packages/date-parser/src/helpers/weekdayIdxFromLuxon.js: -------------------------------------------------------------------------------- 1 | const weekdayIdxFromLuxon = (luxon) => { 2 | const idx = luxon.weekday; 3 | return idx === 7 ? 0 : idx; // our Sunday is 0, while Luxon is 7 4 | }; 5 | 6 | export default weekdayIdxFromLuxon; 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | end_of_line = lf 8 | insert_final_newline = true 9 | charset = utf-8 10 | indent_style = space 11 | indent_size = 2 12 | -------------------------------------------------------------------------------- /packages/date-parser/src/errors.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | 3 | export class InputError extends Error { 4 | } 5 | 6 | export class ParseError extends Error { 7 | } 8 | 9 | export default { 10 | InputError, 11 | ParseError, 12 | }; 13 | -------------------------------------------------------------------------------- /packages/date-parser/cli/build.js: -------------------------------------------------------------------------------- 1 | const { exec } = require('pkg'); 2 | const pjson = require('../package.json'); 3 | 4 | const cliVersion = pjson.version.replace(/\./g, '_'); 5 | exec(['./cli/index.js', '--targets=node12-linux-x64', `--output=./out/date_parser_${cliVersion}`]); 6 | -------------------------------------------------------------------------------- /packages/date-parser/src/helpers/checkDateStringCharacterLimit.js: -------------------------------------------------------------------------------- 1 | import { DATE_STRING_CHARACTER_LIMIT } from '../constants'; 2 | 3 | /** 4 | * @param {String} str A date string 5 | * @return {Boolean} 6 | */ 7 | const exceedLimit = (str) => { 8 | return str.length > DATE_STRING_CHARACTER_LIMIT; 9 | }; 10 | 11 | export default exceedLimit; 12 | -------------------------------------------------------------------------------- /packages/date-parser/src/helpers/luxonFromJSDate.js: -------------------------------------------------------------------------------- 1 | import { DateTime } from 'luxon'; 2 | 3 | /** 4 | * Build a luxon instance from a JS date. 5 | * Because JS date is always stored in UTC, hence, the timezone of the luxon instance is also UTC 6 | */ 7 | export default (jsDate) => { 8 | return DateTime.fromJSDate(jsDate, { zone: 'Etc/UTC' }); 9 | }; 10 | -------------------------------------------------------------------------------- /packages/date-parser/src/helpers/pluralize.js: -------------------------------------------------------------------------------- 1 | // export default (text: string, itemsOrCount: number | Array): string => { 2 | export default (text, count) => { 3 | // const count = itemsOrCount.constructor === Array ? itemsOrCount.length : itemsOrCount; 4 | if (count === 1) return text; 5 | return `${text.replace(/y$/, 'ie').replace(/s$/, 'se')}s`; 6 | }; 7 | -------------------------------------------------------------------------------- /packages/date-parser/src/helpers/dateStructFromDate.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * @param {Date} date 4 | */ 5 | export default (date) => { 6 | return { 7 | year: date.getUTCFullYear(), 8 | month: date.getUTCMonth(), 9 | day: date.getUTCDate(), 10 | hour: date.getUTCHours(), 11 | minute: date.getUTCMinutes(), 12 | second: date.getUTCSeconds(), 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /packages/date-parser/src/helpers/chronoDateStructFromMoment.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * @param {Moment.Moment} moment 4 | */ 5 | export default (moment) => { 6 | return { 7 | year: moment.get('year'), 8 | month: moment.get('month') + 1, 9 | day: moment.get('date'), 10 | hour: moment.get('hour'), 11 | minute: moment.get('minute'), 12 | second: moment.get('second'), 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /packages/date-parser/src/helpers/getHighestLevelDateUnit.test.js: -------------------------------------------------------------------------------- 1 | import getHighestLevelDateUnit from './getHighestLevelDateUnit'; 2 | 3 | describe('getHighestLevelDateUnit', () => { 4 | it('works', () => { 5 | const dateStruct = { 6 | minute: 1, 7 | hour: 2, 8 | year: 2020, 9 | }; 10 | expect(getHighestLevelDateUnit(dateStruct)).toEqual('minute'); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.[t|j]sx?$': 'babel-jest', 4 | }, 5 | collectCoverage: true, 6 | collectCoverageFrom: [ 7 | 'packages/**/src/**/*.js', 8 | '!**/dist/**', 9 | ], 10 | coverageThreshold: { 11 | global: { 12 | branches: 100, 13 | functions: 100, 14 | lines: 100, 15 | statements: 100, 16 | }, 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /packages/date-parser/src/initializers/dayjs.js: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import en from 'dayjs/locale/en'; 3 | import utcPlugin from 'dayjs/plugin/utc'; 4 | import weekdayPlugin from 'dayjs/plugin/weekday'; 5 | import quarterOfYearPlugin from 'dayjs/plugin/quarterOfYear'; 6 | 7 | dayjs.locale({ 8 | ...en, 9 | }); 10 | dayjs.extend(weekdayPlugin); 11 | dayjs.extend(utcPlugin); 12 | dayjs.extend(quarterOfYearPlugin); 13 | -------------------------------------------------------------------------------- /packages/date-parser/src/helpers/luxonFromStruct.test.js: -------------------------------------------------------------------------------- 1 | import luxonFromStruct from './luxonFromStruct'; 2 | 3 | describe('luxonFromStruct', () => { 4 | it('throw errors', () => { 5 | const dateStruct = { 6 | minute: 1, 7 | hour: 2, 8 | year: 2019, 9 | month: 2, 10 | day: 30, 11 | }; 12 | expect(() => { 13 | luxonFromStruct(dateStruct); 14 | }).toThrowError(/unit out of range/i); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /packages/date-parser/src/result.test.js: -------------------------------------------------------------------------------- 1 | import Result from './result'; 2 | import { WEEKDAYS } from './index'; 3 | 4 | 5 | describe('Result tests', () => { 6 | it('work with null result', () => { 7 | const result = new Result({ 8 | ref: null, 9 | index: -1, 10 | text: '', 11 | start: null, 12 | end: null, 13 | weekStartDay: WEEKDAYS.Monday, 14 | }); 15 | 16 | result.asDate(); 17 | result.asTimestamp(); 18 | result.asTimestampUtc(); 19 | result.asLuxon(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /packages/date-parser/src/helpers/getHighestLevelDateUnit.js: -------------------------------------------------------------------------------- 1 | import _forOwn from 'lodash/forOwn'; 2 | import { DATE_UNIT_LEVELS } from '../constants'; 3 | 4 | export default (dateStruct) => { 5 | let highestLevel = -1; 6 | let highestLevelDateUnit; 7 | _forOwn(DATE_UNIT_LEVELS, (level, dateUnit) => { 8 | if (dateUnit in dateStruct) { 9 | /* istanbul ignore else */ 10 | if (level > highestLevel) { 11 | highestLevel = level; 12 | highestLevelDateUnit = dateUnit; 13 | } 14 | } 15 | }); 16 | return highestLevelDateUnit; 17 | }; 18 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Summary 2 | * Short summary of the task, what have been done etc 3 | * Please include screenshots whenever possible (important). 4 | 5 | ## Type of change 6 | - [ ] Bug fix (non-breaking change that fixes an issue) 7 | - [ ] New feature (non-breaking change that adds functionality) 8 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as before) 9 | 10 | ## Breaking change 11 | If this is a breaking change, state the changes. 12 | 13 | ## Checklist 14 | 15 | Please check directly on the box once each of these are done 16 | 17 | - [ ] Update CHANGELOG.md 18 | - [ ] Code Review 19 | -------------------------------------------------------------------------------- /packages/date-parser/src/refiners/ambiguity.js: -------------------------------------------------------------------------------- 1 | import Chrono from 'chrono-node'; 2 | import _isEmpty from 'lodash/isEmpty'; 3 | import _compact from 'lodash/compact'; 4 | 5 | /** 6 | * 7 | * @param {Chrono.ParsedResult} parsedResult 8 | */ 9 | const isAmbiguous = (parsedResult) => { 10 | return _isEmpty(parsedResult.start.knownValues); 11 | }; 12 | 13 | const ambiguityRefiner = new Chrono.Refiner(); 14 | ambiguityRefiner.refine = (text, results) => { 15 | /** 16 | * 17 | * @param {Chrono.ParsedResult} res 18 | */ 19 | return _compact(results.map(res => { 20 | return isAmbiguous(res) ? null : res; 21 | })); 22 | }; 23 | 24 | export default ambiguityRefiner; 25 | -------------------------------------------------------------------------------- /packages/date-parser/src/refiners/v3/timezone.js: -------------------------------------------------------------------------------- 1 | import Chrono from 'chrono-node'; 2 | 3 | const implyTimezoneRegion = (parsedComponent, timezone) => { 4 | if (!parsedComponent.get('timezone')) { 5 | parsedComponent.imply('timezone', timezone); 6 | } 7 | }; 8 | 9 | const timezoneRefiner = new Chrono.Refiner(); 10 | timezoneRefiner.refine = (text, results, { timezone }) => { 11 | /** 12 | * 13 | * @param {Chrono.ParsedResult} res 14 | */ 15 | return results.map(res => { 16 | implyTimezoneRegion(res.start, timezone); 17 | if (res.end) implyTimezoneRegion(res.end, timezone); 18 | 19 | return res; 20 | }); 21 | }; 22 | 23 | export default timezoneRefiner; 24 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | working_directory: ~/holistics-js 5 | docker: 6 | - image: cimg/node:18.16.1 7 | steps: 8 | - checkout 9 | - restore_cache: 10 | name: Restore Yarn Package Cache 11 | keys: 12 | - yarn-packages-{{ checksum "yarn.lock" }} 13 | - run: 14 | name: Install Dependencies 15 | command: yarn install --frozen-lockfile 16 | - save_cache: 17 | name: Save Yarn Package Cache 18 | key: yarn-packages-{{ checksum "yarn.lock" }} 19 | paths: 20 | - ~/.cache/yarn 21 | - run: 22 | name: Run Tests 23 | command: yarn test 24 | -------------------------------------------------------------------------------- /packages/date-parser/src/helpers/momentFromStruct.test.js: -------------------------------------------------------------------------------- 1 | import momentFromStruct from './momentFromStruct'; 2 | 3 | describe('momentFromStruct', () => { 4 | it('validates weekStartDay', () => { 5 | expect(() => momentFromStruct({}, { weekStartDay: undefined })).toThrowError(/invalid weekStartDay/i); 6 | expect(() => momentFromStruct({}, { weekStartDay: null })).toThrowError(/invalid weekStartDay/i); 7 | expect(() => momentFromStruct({}, {})).toThrowError(/invalid weekStartDay/i); 8 | expect(() => momentFromStruct({}, { weekStartDay: -1 })).toThrowError(/invalid weekStartDay/i); 9 | expect(() => momentFromStruct({}, { weekStartDay: 7 })).toThrowError(/invalid weekStartDay/i); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /packages/date-parser/src/helpers/luxonFromChronoStruct.js: -------------------------------------------------------------------------------- 1 | import luxonFromStruct from './luxonFromStruct'; 2 | 3 | /** 4 | * 5 | * @param {Chrono.ParsedComponents} chronoStruct 6 | * @returns {Luxon.DateTime} 7 | */ 8 | const luxonFromChronoStruct = (chronoStruct) => { 9 | return luxonFromStruct({ 10 | year: chronoStruct.get('year'), 11 | month: chronoStruct.get('month'), 12 | day: chronoStruct.get('day'), 13 | hour: chronoStruct.get('hour'), 14 | minute: chronoStruct.get('minute'), 15 | second: chronoStruct.get('second'), 16 | millisecond: chronoStruct.get('millisecond'), 17 | timezone: chronoStruct.get('timezone'), 18 | }); 19 | }; 20 | 21 | export default luxonFromChronoStruct; 22 | -------------------------------------------------------------------------------- /packages/date-parser/src/helpers/timezoneRegion.js: -------------------------------------------------------------------------------- 1 | import { DateTime } from 'luxon'; 2 | import { InputError } from '../errors'; 3 | 4 | class TimezoneRegion { 5 | /** 6 | * @param {String} region The timezone region 7 | */ 8 | constructor (region) { 9 | try { 10 | const date = DateTime.fromISO('2021-05-28T09:10:23', { zone: region }); 11 | if (!date.isValid) { 12 | throw new Error(date.invalidReason); 13 | } 14 | } catch (err) { 15 | throw new InputError(`Invalid timezone region: ${region}, ${err.message}`); 16 | } 17 | this.region = region; 18 | } 19 | 20 | toString () { 21 | return this.region; 22 | } 23 | } 24 | 25 | export default TimezoneRegion; 26 | -------------------------------------------------------------------------------- /packages/date-parser/cli/commands/parse.js: -------------------------------------------------------------------------------- 1 | const dateParser = require('../../dist/date-parser.js'); 2 | 3 | const serializeParsedResult = (parsedResult) => { 4 | if (!parsedResult) return null; 5 | return { 6 | text: parsedResult.text, 7 | ref: parsedResult.ref.toISOString(), 8 | start: parsedResult.start.constructor === String ? parsedResult.start : parsedResult.start.date().toISOString(), 9 | end: parsedResult.end.constructor === String ? parsedResult.end : parsedResult.end.date().toISOString(), 10 | }; 11 | }; 12 | 13 | module.exports = (argv) => { 14 | const { text, ref, options = {} } = argv; 15 | const parsedResult = dateParser.parse(text, ref, options); 16 | return serializeParsedResult(parsedResult); 17 | }; 18 | -------------------------------------------------------------------------------- /packages/date-parser/cli/index.js: -------------------------------------------------------------------------------- 1 | const parse = require('./commands/parse'); 2 | 3 | const wrapHandler = (callback) => { 4 | return (argv) => { 5 | let res = {}; 6 | let exitCode = 0; 7 | try { 8 | res = callback(argv); 9 | } catch (err) { 10 | res = { 11 | error: err.message, 12 | }; 13 | if (argv.debug) res.errorStack = err.stack; 14 | exitCode = 1; 15 | } 16 | console.log(JSON.stringify(res)); 17 | process.exit(exitCode); 18 | }; 19 | }; 20 | 21 | require('yargs') 22 | .command({ 23 | command: 'parse ', 24 | desc: 'parse date text into a date range', 25 | handler: wrapHandler(parse), 26 | }) 27 | .help() 28 | .demandCommand() 29 | .parse(); 30 | -------------------------------------------------------------------------------- /packages/date-parser/src/helpers/momentFromStruct.js: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import en from 'dayjs/locale/en'; 3 | 4 | export default ({ 5 | year, month, day, hour, minute, second, 6 | }, { 7 | weekStartDay, 8 | }) => { 9 | /* eslint-disable-next-line no-param-reassign */ 10 | weekStartDay = parseInt(weekStartDay); 11 | if (Number.isNaN(weekStartDay) || weekStartDay < 0 || weekStartDay > 6) { 12 | throw new Error(`Invalid weekStartDay index: ${weekStartDay}`); 13 | } 14 | 15 | return dayjs.utc() 16 | .year(year) 17 | .month(month) 18 | .date(day) 19 | .hour(hour) 20 | .minute(minute) 21 | .second(second) 22 | .locale({ 23 | ...en, 24 | weekStart: weekStartDay, 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /packages/date-parser/src/helpers/luxonFromStruct.js: -------------------------------------------------------------------------------- 1 | import { DateTime } from 'luxon'; 2 | import { ParseError } from '../errors'; 3 | 4 | export default ({ 5 | year, month, day, hour, minute, second, millisecond, timezone, 6 | }) => { 7 | // Warning: don't use fromObject here because its behavior is inconsistent in the browser 8 | // See https://github.com/holistics/js/pull/15 9 | const datetime = DateTime.utc( 10 | year, 11 | month, 12 | day, 13 | hour, 14 | minute, 15 | second, 16 | millisecond, 17 | ); 18 | 19 | if (!datetime.isValid) { 20 | throw new ParseError(`${datetime.invalidReason}: ${datetime.invalidExplanation}`); 21 | } 22 | 23 | return datetime.setZone(timezone, { keepLocalTime: true }); 24 | }; 25 | -------------------------------------------------------------------------------- /packages/date-parser/src/parsers/guards/ambiguousWeekday.js: -------------------------------------------------------------------------------- 1 | import Chrono from 'chrono-node'; 2 | import { WEEKDAYS_MAP } from '../../constants'; 3 | import { ParseError } from '../../errors'; 4 | 5 | const guard = new Chrono.Parser(); 6 | 7 | guard.pattern = () => { 8 | return new RegExp(`^\\s*((?:last|this|next) )?(${Object.keys(WEEKDAYS_MAP).join('|')})\\s*$`, 'i'); 9 | }; 10 | 11 | /** 12 | * @param {String} text 13 | * @param {Date} ref 14 | * @param {Array} match 15 | */ 16 | guard.extract = (text, ref, match) => { 17 | const modifier = (match[1] || 'last/this/next').trim(); 18 | const weekday = match[2]; 19 | throw new ParseError(`"${match[0]}" is ambiguous. Please try "${weekday} ${modifier} week" instead`); 20 | }; 21 | 22 | export default guard; 23 | -------------------------------------------------------------------------------- /packages/date-parser/src/helpers/getParsedResultBoundaries.js: -------------------------------------------------------------------------------- 1 | import _some from 'lodash/some'; 2 | 3 | /** 4 | * Sort the Chrono parsed results 5 | * 6 | * @param {Chrono.ParsedResult} parsedResults 7 | */ 8 | const getParsedResultBoundaries = (parsedResults) => { 9 | const sortedResults = parsedResults.slice().sort((a, b) => { 10 | if (a.end.moment().isBefore(b.start.moment())) return -1; 11 | if (a.start.moment().isAfter(b.end.moment())) return 1; 12 | return 0; 13 | }); 14 | const hasOrderChanged = _some(sortedResults, (r, i) => parsedResults[i] !== r); 15 | const first = sortedResults[0]; 16 | const last = sortedResults[sortedResults.length - 1]; 17 | return { first, last, hasOrderChanged }; 18 | }; 19 | 20 | export default getParsedResultBoundaries; 21 | -------------------------------------------------------------------------------- /packages/date-parser/src/refiners/timezone.js: -------------------------------------------------------------------------------- 1 | import Chrono from 'chrono-node'; 2 | 3 | /** 4 | * 5 | * @param {Chrono.ParsedComponents} parsedComponent 6 | */ 7 | const implyTimezone = (parsedComponent, timezoneOffset) => { 8 | parsedComponent.imply('timezoneOffset', (timezoneOffset || 0)); 9 | }; 10 | 11 | const timezoneRefiner = new Chrono.Refiner(); 12 | timezoneRefiner.refine = (text, results, { timezoneOffset }) => { 13 | /** 14 | * 15 | * @param {Chrono.ParsedResult} res 16 | */ 17 | return results.map(res => { 18 | /* istanbul ignore else */ 19 | if (res.start) implyTimezone(res.start, timezoneOffset); 20 | if (res.end) implyTimezone(res.end, timezoneOffset); 21 | return res; 22 | }); 23 | }; 24 | 25 | export default timezoneRefiner; 26 | -------------------------------------------------------------------------------- /packages/sample/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@holistics/sample", 3 | "version": "1.1.1", 4 | "description": "Sample package", 5 | "author": "Hoang Do ", 6 | "homepage": "https://github.com/holistics/js#readme", 7 | "license": "MIT", 8 | "main": "dist/sample.js", 9 | "files": [ 10 | "dist" 11 | ], 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/holistics/js.git" 15 | }, 16 | "publishConfig": { 17 | "access": "public" 18 | }, 19 | "scripts": { 20 | "test": "echo \"Error: run tests from root\" && exit 1", 21 | "preversion": "yarn build", 22 | "build": "webpack --config webpack.config.js" 23 | }, 24 | "bugs": { 25 | "url": "https://github.com/holistics/js/issues" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/date-parser/src/parsers/year.js: -------------------------------------------------------------------------------- 1 | import Chrono from 'chrono-node'; 2 | 3 | const parser = new Chrono.Parser(); 4 | 5 | parser.pattern = () => { 6 | return new RegExp('(\\d\\d\\d\\d)'); 7 | }; 8 | 9 | /** 10 | * @param {String} text 11 | * @param {Date} ref 12 | * @param {Array} match 13 | */ 14 | parser.extract = (text, ref, match, opt) => { 15 | const year = parseInt(match[1]); 16 | const month = 1; 17 | const day = 1; 18 | const { timezone } = opt; 19 | 20 | return new Chrono.ParsedResult({ 21 | ref, 22 | text: match[0], 23 | index: match.index, 24 | tags: { yearParser: true }, 25 | start: { 26 | year, month, day, timezone, 27 | }, 28 | end: { 29 | year: year + 1, month, day, timezone, 30 | }, 31 | }); 32 | }; 33 | 34 | export default parser; 35 | -------------------------------------------------------------------------------- /packages/date-parser/src/helpers/dateStructFromLuxon.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * @param {Luxon.DateTime} luxon instance 4 | */ 5 | export default (luxon) => { 6 | return { 7 | year: luxon.year, 8 | month: luxon.month, // luxon's month starts at 1, same as our date struct 9 | day: luxon.day, 10 | hour: luxon.hour, 11 | minute: luxon.minute, 12 | second: luxon.second, 13 | millisecond: luxon.millisecond, 14 | timezone: luxon.zoneName, 15 | // Because this struct is passed as a Chrono result, some internal Chrono logic depends 16 | // on timezoneOffset for calculation, so we set it to be 0 to avoid breaking those logic. 17 | // 18 | // This won't affect the final result because our v3 logic will process tz region and skip this offset 19 | // 20 | timezoneOffset: 0, 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /packages/date-parser/src/helpers/splitInputString.js: -------------------------------------------------------------------------------- 1 | import { 2 | DATE_RANGE_PATTERNS, 3 | } from '../constants'; 4 | 5 | const splitInputStr = (str) => { 6 | let parts = [str]; 7 | let isRangeEndInclusive = true; 8 | let rangeSeparator; 9 | let matches; 10 | 11 | if (str.match(DATE_RANGE_PATTERNS.rangeEndInclusive)) { 12 | matches = str.match(DATE_RANGE_PATTERNS.rangeEndInclusive); 13 | isRangeEndInclusive = true; 14 | } else if (str.match(DATE_RANGE_PATTERNS.rangeEndExclusive)) { 15 | matches = str.match(DATE_RANGE_PATTERNS.rangeEndExclusive); 16 | isRangeEndInclusive = false; 17 | } 18 | 19 | if (matches) { 20 | rangeSeparator = matches[2]; 21 | parts = [matches[1], matches[3]]; 22 | } 23 | 24 | return { 25 | isRange: !!rangeSeparator, 26 | parts, 27 | rangeSeparator, 28 | isRangeEndInclusive, 29 | }; 30 | }; 31 | 32 | export default splitInputStr; 33 | -------------------------------------------------------------------------------- /packages/date-parser/src/helpers/truncateDateStruct.js: -------------------------------------------------------------------------------- 1 | import _pick from 'lodash/pick'; 2 | 3 | const DATE_PARTS_FOR_DATE_UNIT = { 4 | year: ['year'], 5 | quarter: ['year', 'month'], 6 | month: ['year', 'month'], 7 | week: ['year', 'month', 'day'], 8 | day: ['year', 'month', 'day'], 9 | hour: ['year', 'month', 'day', 'hour'], 10 | minute: ['year', 'month', 'day', 'hour', 'minute'], 11 | second: ['year', 'month', 'day', 'hour', 'minute', 'second'], 12 | }; 13 | 14 | // monthStartAtZero: 15 | // - v1 uses dayjs which starts at 0 16 | // - v3 uses luxon which starts at 1 17 | export default (dateStruct, dateUnit, monthStartAtZero = true) => { 18 | return { 19 | year: 1, 20 | month: monthStartAtZero ? 0 : 1, 21 | day: 1, 22 | hour: 0, 23 | minute: 0, 24 | second: 0, 25 | ..._pick(dateStruct, DATE_PARTS_FOR_DATE_UNIT[dateUnit]), 26 | timezone: dateStruct.timezone, 27 | }; 28 | }; 29 | -------------------------------------------------------------------------------- /packages/sample/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const pkg = require('./package.json'); 3 | 4 | const filename = pkg.main.replace('dist/', ''); 5 | const library = pkg.name.replace('@holistics/', '').replace(/\b\S/g, t => t.toUpperCase()).replace('-', ''); 6 | 7 | module.exports = { 8 | mode: 'production', 9 | entry: './src/index.js', 10 | output: { 11 | path: path.resolve(__dirname, 'dist'), 12 | filename, 13 | library, 14 | libraryTarget: 'umd', 15 | umdNamedDefine: true, 16 | globalObject: "typeof self !== 'undefined' ? self : this", 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.m?js$/, 22 | exclude: /(node_modules|bower_components)/, 23 | use: { 24 | loader: 'babel-loader', 25 | options: { 26 | presets: ['@babel/preset-env'], 27 | plugins: ['@babel/plugin-transform-runtime'], 28 | }, 29 | }, 30 | }, 31 | ], 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /packages/date-parser/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const pkg = require('./package.json'); 3 | 4 | const filename = pkg.main.replace('dist/', ''); 5 | const library = pkg.name.replace('@holistics/', '').replace(/\b\S/g, t => t.toUpperCase()).replace('-', ''); 6 | 7 | module.exports = { 8 | mode: 'production', 9 | entry: './src/index.js', 10 | output: { 11 | path: path.resolve(__dirname, 'dist'), 12 | filename, 13 | library, 14 | libraryTarget: 'umd', 15 | umdNamedDefine: true, 16 | globalObject: "typeof self !== 'undefined' ? self : this", 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.m?js$/, 22 | exclude: /(node_modules|bower_components)/, 23 | use: { 24 | loader: 'babel-loader', 25 | options: { 26 | presets: ['@babel/preset-env'], 27 | plugins: ['@babel/plugin-transform-runtime'], 28 | }, 29 | }, 30 | }, 31 | ], 32 | }, 33 | externals: { 34 | 'chrono-node': 'chrono-node', 35 | dayjs: 'dayjs', 36 | }, 37 | }; 38 | -------------------------------------------------------------------------------- /packages/date-parser/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@holistics/date-parser", 3 | "version": "3.3.0", 4 | "description": "Date parser", 5 | "author": "Dat Bui ", 6 | "homepage": "https://github.com/holistics/js#readme", 7 | "license": "MIT", 8 | "main": "dist/date-parser.js", 9 | "files": [ 10 | "dist" 11 | ], 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/holistics/js.git" 15 | }, 16 | "publishConfig": { 17 | "access": "public" 18 | }, 19 | "scripts": { 20 | "test": "echo \"Error: run tests from root\" && exit 1", 21 | "preversion": "yarn build", 22 | "build": "webpack --config webpack.config.js", 23 | "build-cli": "yarn build && node ./cli/build.js" 24 | }, 25 | "bugs": { 26 | "url": "https://github.com/holistics/js/issues" 27 | }, 28 | "dependencies": { 29 | "chrono-node": "1.4.8", 30 | "dayjs": "^1.8.19", 31 | "lodash": "^4.17.15", 32 | "luxon": "^3.3.0" 33 | }, 34 | "devDependencies": { 35 | "pkg": "^5.8.1", 36 | "yargs": "^15.1.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/date-parser/src/parsers/v3/constants.js: -------------------------------------------------------------------------------- 1 | import Chrono from 'chrono-node'; 2 | import dateStructFromLuxon from '../../helpers/dateStructFromLuxon'; 3 | 4 | const parser = new Chrono.Parser(); 5 | 6 | parser.pattern = () => { 7 | return new RegExp('(beginning|now)', 'i'); 8 | }; 9 | 10 | /** 11 | * @param {String} text 12 | * @param {Date} ref 13 | * @param {Array} match 14 | * @param {Object} opt 15 | */ 16 | parser.extract = (text, ref, match, opt) => { 17 | const date = match[1].toLowerCase(); 18 | const { luxonRefInTargetTz } = opt; 19 | 20 | let start; 21 | let end; 22 | 23 | /* istanbul ignore else */ 24 | if (date === 'beginning') { 25 | start = { 26 | year: 1970, month: 1, day: 1, hour: 0, minute: 0, second: 0, timezone: 'Etc/UTC', 27 | }; 28 | } else if (date === 'now') { 29 | start = dateStructFromLuxon(luxonRefInTargetTz); 30 | } 31 | 32 | return new Chrono.ParsedResult({ 33 | ref, 34 | text: match[0], 35 | index: match.index, 36 | tags: { constantParser: true }, 37 | start, 38 | end, 39 | }); 40 | }; 41 | 42 | export default parser; 43 | -------------------------------------------------------------------------------- /packages/date-parser/src/parsers/constants.js: -------------------------------------------------------------------------------- 1 | import Chrono from 'chrono-node'; 2 | import dateStructFromDate from '../helpers/dateStructFromDate'; 3 | 4 | const parser = new Chrono.Parser(); 5 | 6 | parser.pattern = () => { 7 | return new RegExp('(beginning|now)', 'i'); 8 | }; 9 | 10 | /** 11 | * @param {String} text 12 | * @param {Date} ref 13 | * @param {Array} match 14 | */ 15 | parser.extract = (text, ref, match) => { 16 | const date = match[1].toLowerCase(); 17 | 18 | let start; 19 | let end; 20 | /* istanbul ignore else */ 21 | if (date === 'beginning') { 22 | start = { 23 | year: 1970, month: 1, day: 1, hour: 0, minute: 0, second: 0, 24 | }; 25 | } else if (date === 'now') { 26 | const refDateStruct = dateStructFromDate(ref); 27 | const nowDateStruct = { ...refDateStruct, month: refDateStruct.month + 1 }; 28 | start = nowDateStruct; 29 | } 30 | 31 | return new Chrono.ParsedResult({ 32 | ref, 33 | text: match[0], 34 | index: match.index, 35 | tags: { constantParser: true }, 36 | start, 37 | end, 38 | }); 39 | }; 40 | 41 | export default parser; 42 | -------------------------------------------------------------------------------- /packages/sample/LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Holistics 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/date-parser/LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Holistics 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | node: true, 6 | 'jest/globals': true 7 | }, 8 | extends: [ 9 | 'airbnb-base', 10 | ], 11 | plugins: ['jest'], 12 | globals: { 13 | Atomics: 'readonly', 14 | SharedArrayBuffer: 'readonly', 15 | }, 16 | parserOptions: { 17 | ecmaVersion: 2018, 18 | sourceType: 'module', 19 | }, 20 | rules: { 21 | "space-before-function-paren": ["error", "always"], 22 | "no-underscore-dangle": "off", 23 | "no-param-reassign": ["error", { "props": false }], 24 | "no-shadow": "warn", 25 | "max-len": ["error", { 26 | "code": 180, 27 | "ignorePattern": "^\\s*/\\*.*\\*/\\s*$", 28 | "ignoreComments": true 29 | }], 30 | "radix": ["warn", "as-needed"], 31 | "arrow-parens": "off", 32 | "arrow-body-style": "off", 33 | "object-curly-newline": "warn", 34 | "prefer-destructuring": ["error", {"object": true, "array": false}], 35 | "func-names": "off", 36 | "no-restricted-imports": ["error", { 37 | "paths": [{ 38 | "name": "uiv", 39 | "message": "Please use the globally registered component/directive instead" 40 | }] 41 | }], 42 | "import/prefer-default-export": "off", 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /packages/date-parser/src/parsers/v3/today.js: -------------------------------------------------------------------------------- 1 | import Chrono from 'chrono-node'; 2 | import truncateDateStruct from '../../helpers/truncateDateStruct'; 3 | import dateStructFromLuxon from '../../helpers/dateStructFromLuxon'; 4 | import luxonFromStruct from '../../helpers/luxonFromStruct'; 5 | 6 | const parser = new Chrono.Parser(); 7 | 8 | parser.pattern = () => { 9 | return new RegExp('(today|yesterday|tomorrow)', 'i'); 10 | }; 11 | 12 | /** 13 | * @param {String} text 14 | * @param {Date} ref 15 | * @param {Array} match 16 | */ 17 | parser.extract = (text, ref, match, opt) => { 18 | const { luxonRefInTargetTz } = opt; 19 | 20 | const date = match[1].toLowerCase(); 21 | let value = 0; 22 | if (date === 'yesterday') { 23 | value = -1; 24 | } else if (date === 'tomorrow') { 25 | value = 1; 26 | } 27 | 28 | const truncatedStruct = truncateDateStruct(dateStructFromLuxon(luxonRefInTargetTz), 'day', false); 29 | const truncatedLuxon = luxonFromStruct(truncatedStruct); 30 | 31 | const startLuxon = truncatedLuxon.plus({ days: value }); 32 | const endLuxon = startLuxon.plus({ days: 1 }); 33 | 34 | return new Chrono.ParsedResult({ 35 | ref, 36 | text: match[0], 37 | index: match.index, 38 | tags: { todayParser: true }, 39 | start: dateStructFromLuxon(startLuxon), 40 | end: dateStructFromLuxon(endLuxon), 41 | }); 42 | }; 43 | 44 | export default parser; 45 | -------------------------------------------------------------------------------- /packages/date-parser/src/parsers/today.js: -------------------------------------------------------------------------------- 1 | import Chrono from 'chrono-node'; 2 | import dateStructFromDate from '../helpers/dateStructFromDate'; 3 | import momentFromStruct from '../helpers/momentFromStruct'; 4 | import chronoDateStructFromMoment from '../helpers/chronoDateStructFromMoment'; 5 | import truncateDateStruct from '../helpers/truncateDateStruct'; 6 | 7 | const parser = new Chrono.Parser(); 8 | 9 | parser.pattern = () => { 10 | return new RegExp('(today|yesterday|tomorrow)', 'i'); 11 | }; 12 | 13 | /** 14 | * @param {String} text 15 | * @param {Date} ref 16 | * @param {Array} match 17 | */ 18 | parser.extract = (text, ref, match, opt) => { 19 | const date = match[1].toLowerCase(); 20 | let value = 0; 21 | if (date === 'yesterday') { 22 | value = -1; 23 | } else if (date === 'tomorrow') { 24 | value = 1; 25 | } 26 | 27 | const refDateStruct = truncateDateStruct(dateStructFromDate(ref), 'day'); 28 | let startMoment = momentFromStruct(refDateStruct, { weekStartDay: opt.weekStartDay }); 29 | startMoment = startMoment.add(value, 'day'); 30 | 31 | const endMoment = startMoment.add(1, 'day'); 32 | 33 | return new Chrono.ParsedResult({ 34 | ref, 35 | text: match[0], 36 | index: match.index, 37 | tags: { todayParser: true }, 38 | start: chronoDateStructFromMoment(startMoment), 39 | end: chronoDateStructFromMoment(endMoment), 40 | }); 41 | }; 42 | 43 | export default parser; 44 | -------------------------------------------------------------------------------- /packages/date-parser/src/initializers/chrono-node.js: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import { ParsedComponents } from 'chrono-node'; 3 | 4 | const overrideDayjs = () => { 5 | // Monkey-patch the dayjs function so that: 6 | // * the result dayjs object has the plugins that we need (see `./dayjs`). 7 | // * the utcOffset of the result is set according to timezoneOffset 8 | ParsedComponents.prototype.dayjs = function () { 9 | let result = dayjs(); 10 | 11 | result = result.year(this.get('year')); 12 | result = result.month(this.get('month') - 1); 13 | result = result.date(this.get('day')); 14 | result = result.hour(this.get('hour')); 15 | result = result.minute(this.get('minute')); 16 | result = result.second(this.get('second')); 17 | result = result.millisecond(this.get('millisecond')); 18 | 19 | // Javascript Date Object return minus timezone offset 20 | const currentTimezoneOffset = result.utcOffset(); 21 | const targetTimezoneOffset = this.get('timezoneOffset') !== undefined ? this.get('timezoneOffset') : currentTimezoneOffset; 22 | 23 | const adjustTimezoneOffset = targetTimezoneOffset - currentTimezoneOffset; 24 | result = result.add(-adjustTimezoneOffset, 'minute'); 25 | /* BEGIN MONKEY PATCH */ 26 | result = result.utcOffset(targetTimezoneOffset); // without this, the result would have timezone offset of the process 27 | /* END MONKEY PATCH */ 28 | 29 | return result; 30 | }; 31 | }; 32 | 33 | overrideDayjs(); 34 | -------------------------------------------------------------------------------- /packages/date-parser/src/refiners/implier.js: -------------------------------------------------------------------------------- 1 | import Chrono from 'chrono-node'; 2 | import getHighestLevelDateUnit from '../helpers/getHighestLevelDateUnit'; 3 | 4 | /** 5 | * 6 | * @param {Chrono.ParsedComponents} parsedComponent 7 | */ 8 | const implyDefaults = (parsedComponent) => { 9 | parsedComponent.imply('hour', 0); 10 | parsedComponent.imply('minute', 0); 11 | parsedComponent.imply('second', 0); 12 | }; 13 | 14 | /** 15 | * 16 | * @param {Chrono.ParsedComponents} start 17 | * @param {Boolean} singleTimePoint 18 | */ 19 | const implyEnd = (start) => { 20 | const end = start.clone(); 21 | end.impliedValues = { 22 | ...end.impliedValues, 23 | ...end.knownValues, 24 | }; 25 | end.knownValues = {}; 26 | 27 | // increment the highest-level known date unit 28 | const incrementedUnit = getHighestLevelDateUnit(start.knownValues); 29 | end.imply(incrementedUnit, start.get(incrementedUnit) + 1); 30 | return end; 31 | }; 32 | 33 | /** 34 | * 35 | * @param {Chrono.ParsedResult} res 36 | */ 37 | const implyResult = (res) => { 38 | implyDefaults(res.start); 39 | if (res.end) { 40 | implyDefaults(res.end); 41 | } else { 42 | res.end = implyEnd(res.start); 43 | } 44 | return res; 45 | }; 46 | 47 | const implier = new Chrono.Refiner(); 48 | implier.refine = (text, results) => { 49 | /** 50 | * 51 | * @param {Chrono.ParsedResult} res 52 | */ 53 | return results.map(res => implyResult(res)); 54 | }; 55 | 56 | export default implier; 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "holistics-js", 3 | "version": "1.0.0", 4 | "description": "Holistics open-source javascript libraries", 5 | "private": true, 6 | "workspaces": [ 7 | "packages/*" 8 | ], 9 | "repository": "https://github.com/holistics/js", 10 | "author": "Holistics", 11 | "license": "MIT", 12 | "scripts": { 13 | "lerna:publish": "yarn test && lerna publish", 14 | "lerna:version": "yarn test && lerna version --conventional-commits", 15 | "lint": "eslint --ext .js --ignore-path .gitignore .", 16 | "test": "env TZ='Europe/Berlin' LC_ALL='de-DE' jest packages/date-parser/src/dateParserV3.test.js --coverage=false && env TZ='Asia/Singapore' LC_ALL='de-De' jest" 17 | }, 18 | "devDependencies": { 19 | "@babel/cli": "^7.22.9", 20 | "@babel/core": "^7.22.9", 21 | "@babel/plugin-transform-runtime": "^7.22.9", 22 | "@babel/preset-env": "^7.22.9", 23 | "babel-jest": "^29.6.0", 24 | "babel-loader": "^9.1.3", 25 | "eslint": "^8.44.0", 26 | "eslint-config-airbnb-base": "^14.0.0", 27 | "eslint-plugin-import": "^2.19.1", 28 | "eslint-plugin-jest": "^23.2.0", 29 | "husky": "^4.2.5", 30 | "jest": "^29.6.0", 31 | "lerna": "^7.3.0", 32 | "lint-staged": "^9.5.0", 33 | "webpack": "^5.88.2", 34 | "webpack-cli": "^5.1.4" 35 | }, 36 | "husky": { 37 | "hooks": { 38 | "pre-commit": "lint-staged" 39 | } 40 | }, 41 | "lint-staged": { 42 | "*.js": [ 43 | "eslint --fix", 44 | "git add" 45 | ] 46 | }, 47 | "engines": { 48 | "node": "18.x.x", 49 | "yarn": ">=1.21.0" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # IDEs and editors 33 | .idea 34 | .project 35 | .classpath 36 | .c9/ 37 | *.launch 38 | .settings/ 39 | *.sublime-workspace 40 | 41 | # IDE - VSCode 42 | .vscode/* 43 | !.vscode/settings.json 44 | !.vscode/tasks.json 45 | !.vscode/launch.json 46 | !.vscode/extensions.json 47 | 48 | # misc 49 | .sass-cache 50 | connect.lock 51 | typings 52 | 53 | # Logs 54 | logs 55 | *.log 56 | npm-debug.log* 57 | yarn-debug.log* 58 | yarn-error.log* 59 | 60 | 61 | # Dependency directories 62 | node_modules/ 63 | jspm_packages/ 64 | 65 | # Optional npm cache directory 66 | .npm 67 | 68 | # Optional eslint cache 69 | .eslintcache 70 | 71 | # Optional REPL history 72 | .node_repl_history 73 | 74 | # Output of 'npm pack' 75 | *.tgz 76 | 77 | # Yarn Integrity file 78 | .yarn-integrity 79 | 80 | # dotenv environment variables file 81 | .env 82 | 83 | # next.js build output 84 | .next 85 | 86 | # Lerna 87 | lerna-debug.log 88 | 89 | # System Files 90 | .DS_Store 91 | Thumbs.db 92 | 93 | # Builds 94 | dist/ 95 | out/ 96 | 97 | .vscode -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # holistics-js 2 | 3 | > Holistics open-source javascript packages 4 | 5 | [![holistics](https://circleci.com/gh/holistics/js.svg?style=svg)](https://circleci.com/gh/holistics/js) 6 | 7 | ## Contributing 8 | 9 | ### Prerequisites 10 | 11 | * NodeJS 12 | * Yarn 13 | 14 | ### Setting-up 15 | 16 | * Clone this repo 17 | * Bootstrap 18 | 19 | ```bash 20 | cd path/to/repo/root 21 | yarn install 22 | ``` 23 | 24 | ### Creating a new package 25 | 26 | This is a monorepo containing many packages. To quickly create a new package: 27 | 28 | * Copy `sample` package 29 | 30 | ```bash 31 | cd path/to/repo/root 32 | cp -R packages/sample packages/your-new-package 33 | ``` 34 | 35 | * Edit new package's `package.json` 36 | 37 | ```bash 38 | vim packages/your-new-package/package.json 39 | # remember to change package's 'name', 'main' and set its 'version' back to '0.0.0' 40 | ``` 41 | 42 | * Update new package's `webpack.config.js` if needed 43 | 44 | ### Publishing 45 | 46 | > For Holistics team members only 47 | 48 | 1. Create a branch for the new version, e.g. `2.11.0` 49 | 2. Run `yarn lerna:version` at the repo **root** 50 | * It will automatically 51 | * Push a new commit which 52 | * Bumps version in `package.json` 53 | * Adds a Changelog entry for the new version 54 | * Build the new version `dist` 55 | * Push a new tag for the new version on the new commit 56 | 3. Create a PR and merge into `master` 57 | 4. Publish the new `dist` by 58 | 1. `cd ./packages/your-package` 59 | 2. `npm publish` to both public npm and Holistics internal packages. Hint: use `npmrc` to manage your profiles 60 | 61 | TODO: automate this process into a single trigger. 62 | -------------------------------------------------------------------------------- /packages/date-parser/src/index.js: -------------------------------------------------------------------------------- 1 | import { parse as parseV1 } from './dateParserV1'; 2 | import { parse as parseV3 } from './dateParserV3'; 3 | import { 4 | WEEKDAYS, OUTPUT_TYPES, PARSER_VERSION_1, PARSER_VERSION_3, 5 | } from './constants'; 6 | import Errors from './errors'; 7 | 8 | /** 9 | * Parse the given date string into Chrono.ParsedResult 10 | * @param {String} str The date string to parse 11 | * @param {String|Date} ref Reference date 12 | * @param {Object} options 13 | * @param {Number} options.timezoneOffset Timezone offset in minutes 14 | * @param {OUTPUT_TYPES} options.output Type of the output dates 15 | * @param {Number} weekStartDay The weekday chosen to be the start of a week. See WEEKDAYS constant for possible values 16 | * @param {Number} parserVersion V1 supports timezone offset while V3 supports timezone region only. Use for backward compatabiblity 17 | * @param {String} timezoneRegion timezone region, only available in V3 parser 18 | * @return {ChronoNode.ParsedResult|Array} 19 | */ 20 | export const parse = (str, ref, { 21 | timezoneOffset = 0, 22 | timezoneRegion = 'Etc/UTC', 23 | output = OUTPUT_TYPES.parsed_component, 24 | weekStartDay = WEEKDAYS.Monday, 25 | parserVersion = PARSER_VERSION_1, 26 | } = {}) => { 27 | if (parserVersion === PARSER_VERSION_3) { 28 | return parseV3(str, ref, { timezoneRegion, output, weekStartDay }); 29 | } 30 | 31 | // V1 parser that supports timezone offset 32 | return parseV1(str, ref, { timezoneOffset, output, weekStartDay }); 33 | }; 34 | 35 | export { WEEKDAYS, OUTPUT_TYPES } from './constants'; 36 | export { default as Errors } from './errors'; 37 | 38 | export default { 39 | parse, 40 | WEEKDAYS, 41 | OUTPUT_TYPES, 42 | Errors, 43 | }; 44 | -------------------------------------------------------------------------------- /packages/date-parser/src/options.js: -------------------------------------------------------------------------------- 1 | import ChronoNode from 'chrono-node'; 2 | 3 | import ambiguousWeekdayGuard from './parsers/guards/ambiguousWeekday'; 4 | 5 | import constantsParser from './parsers/constants'; 6 | import todayParser from './parsers/today'; 7 | import yearParser from './parsers/year'; 8 | import weekdayParser from './parsers/weekday'; 9 | import xAgoParser from './parsers/xAgo'; 10 | import lastXParser from './parsers/lastX'; 11 | 12 | import implier from './refiners/implier'; 13 | import timezoneRefiner from './refiners/timezone'; 14 | import ambiguityRefiner from './refiners/ambiguity'; 15 | 16 | const { parser, refiner } = ChronoNode; 17 | const parserConfig = { strict: true }; 18 | 19 | export default { 20 | parsers: [ 21 | ambiguousWeekdayGuard, 22 | 23 | constantsParser, 24 | todayParser, 25 | yearParser, 26 | weekdayParser, 27 | xAgoParser, 28 | lastXParser, 29 | 30 | new parser.ENISOFormatParser(parserConfig), 31 | new parser.ENDeadlineFormatParser(parserConfig), 32 | new parser.ENMonthNameLittleEndianParser(parserConfig), 33 | new parser.ENMonthNameMiddleEndianParser(parserConfig), 34 | new parser.ENMonthNameParser(parserConfig), 35 | new parser.ENSlashDateFormatParser(parserConfig), 36 | new parser.ENSlashDateFormatStartWithYearParser(parserConfig), 37 | new parser.ENSlashMonthFormatParser(parserConfig), 38 | new parser.ENTimeExpressionParser(parserConfig), 39 | ], 40 | refiners: [ 41 | new refiner.OverlapRemovalRefiner(), 42 | new refiner.ForwardDateRefiner(), 43 | 44 | // English 45 | new refiner.ENMergeDateTimeRefiner(), 46 | new refiner.ENMergeDateRangeRefiner(), 47 | new refiner.ENPrioritizeSpecificDateRefiner(), 48 | 49 | timezoneRefiner, 50 | implier, 51 | ambiguityRefiner, 52 | ], 53 | }; 54 | -------------------------------------------------------------------------------- /packages/date-parser/src/refiners/v3/implier.js: -------------------------------------------------------------------------------- 1 | import Chrono from 'chrono-node'; 2 | import getHighestLevelDateUnit from '../../helpers/getHighestLevelDateUnit'; 3 | import luxonFromChronoStruct from '../../helpers/luxonFromChronoStruct'; 4 | import dateStructFromLuxon from '../../helpers/dateStructFromLuxon'; 5 | 6 | /** 7 | * 8 | * @param {Chrono.ParsedComponents} parsedComponent 9 | * Chrono set the hour at 12:00 by default, the imply here set it back to 00:00 10 | */ 11 | const implyDefaults = (parsedComponent) => { 12 | parsedComponent.imply('hour', 0); 13 | parsedComponent.imply('minute', 0); 14 | parsedComponent.imply('second', 0); 15 | }; 16 | 17 | /** 18 | * @param {Chorno.ParsedComponents} start 19 | * @returns Chrono.ParsedComponent 20 | */ 21 | const implyWithLuxon = (start) => { 22 | const end = start.clone(); 23 | 24 | // increment the highest-level known date unit 25 | const incrementedUnit = getHighestLevelDateUnit(start.knownValues) || 'millisecond'; 26 | const incremental = {}; 27 | incremental[`${incrementedUnit}s`] = 1; // days, months, years... 28 | const luxonInstance = luxonFromChronoStruct(start).plus(incremental); 29 | 30 | end.impliedValues = dateStructFromLuxon(luxonInstance); 31 | end.knownValues = {}; 32 | 33 | return end; 34 | }; 35 | 36 | /** 37 | * 38 | * @param {Chrono.ParsedResult} res 39 | */ 40 | const implyResult = (res) => { 41 | implyDefaults(res.start); 42 | if (res.end) { 43 | implyDefaults(res.end); 44 | } else { 45 | res.end = implyWithLuxon(res.start); 46 | } 47 | return res; 48 | }; 49 | 50 | const implier = new Chrono.Refiner(); 51 | implier.refine = (text, results, opt) => { 52 | /** 53 | * 54 | * @param {Chrono.ParsedResult} res 55 | */ 56 | return results.map(res => implyResult(res, opt)); 57 | }; 58 | 59 | export default implier; 60 | -------------------------------------------------------------------------------- /packages/date-parser/src/helpers/startEndOfCustom.js: -------------------------------------------------------------------------------- 1 | import { WEEKDAYS_MAP } from '../constants'; 2 | import weekdayIdxFromLuxon from './weekdayIdxFromLuxon'; 3 | 4 | /** 5 | * 6 | * @param {Luxon.DateTime} currentDate 7 | * @param {WEEKDAYS_MAP} wsdIdx 8 | * @returns number 9 | */ 10 | const shiftRange = (currentDate, wsdIdx) => { 11 | const currentWeekday = weekdayIdxFromLuxon(currentDate); 12 | return (currentWeekday - wsdIdx + 7) % 7; 13 | }; 14 | 15 | // The DateTime.startOf, DateTime.endOf function of luxon doesn't support week start day other than Monday 16 | // Here we add extra logic to support custom week start day 17 | 18 | /** 19 | * 20 | * @param {Luxon.DateTime} luxon 21 | * @param {String} dateUnit 22 | * @param {WEEKDAYS_MAP} weekStarDayIdx 23 | * @returns Luxon.DateTime 24 | */ 25 | const startOfCustom = (luxon, dateUnit, weekStarDayIdx) => { 26 | if (dateUnit !== 'week' || weekStarDayIdx === WEEKDAYS_MAP.Monday) { return luxon.startOf(dateUnit); } 27 | 28 | return luxon 29 | .minus({ days: shiftRange(luxon, weekStarDayIdx) }) // shift to the start of week 30 | .startOf('day'); // truncate the day, this follows the behavior of the original DateTime.startOf 31 | }; 32 | 33 | /** 34 | * 35 | * @param {Luxon.DateTime} luxon 36 | * @param {String} dateUnit 37 | * @param {WEEKDAYS_MAP} weekStarDayIdx 38 | * @returns Luxon.DateTime 39 | */ 40 | const endOfCustom = (luxon, dateUnit, weekStarDayIdx) => { 41 | if (dateUnit !== 'week' || weekStarDayIdx === WEEKDAYS_MAP.Monday) { return luxon.endOf(dateUnit); } 42 | 43 | const startOfWeek = luxon.minus({ days: shiftRange(luxon, weekStarDayIdx) }); 44 | 45 | return startOfWeek 46 | .plus({ days: 6 }) // shift to the end of week 47 | .endOf('day'); // truncate the day to end of day, this follows the behavior of the original DateTime.endOf 48 | }; 49 | 50 | export { startOfCustom, endOfCustom }; 51 | -------------------------------------------------------------------------------- /packages/date-parser/src/parsers/yearMonth.js: -------------------------------------------------------------------------------- 1 | import Chrono from 'chrono-node'; 2 | import momentFromStruct from '../helpers/momentFromStruct'; 3 | import chronoDateStructFromMoment from '../helpers/chronoDateStructFromMoment'; 4 | 5 | const parser = new Chrono.Parser(); 6 | 7 | parser.pattern = () => { 8 | /* 9 | Date format with slash "/" between numbers like ENSlashDateFormatParser, 10 | but this parser expect year before month. 11 | - YYYY/MM 12 | - YYYY-MM 13 | */ 14 | 15 | const delimiter = '[\\/-]'; 16 | const year = '([0-9]{4})'; 17 | const month = '(?:([0-9]{1,2}))'; 18 | 19 | // Prevent matching strings that look like a code. E.g: 20 | // - Match : "2000-10, ..." 21 | // - Not Match: "2000-10ABC..." 22 | const endOfADate = '(?=\\W|$)'; 23 | 24 | const PATTERN = new RegExp( 25 | year + delimiter + month + endOfADate, 26 | 'i', 27 | ); 28 | 29 | return PATTERN; 30 | }; 31 | 32 | /** 33 | * @param {String} text 34 | * @param {Date} ref 35 | * @param {Array} match 36 | */ 37 | parser.extract = (text, ref, match, opt) => { 38 | const year = parseInt(match[1]); 39 | const month = parseInt(match[2]); 40 | const day = 1; 41 | 42 | if (month < 1 || month > 12) return null; 43 | 44 | const { timezone } = opt; 45 | 46 | const startMoment = momentFromStruct( 47 | { 48 | year, 49 | month: month - 1, // 0 is Jan 50 | day, 51 | hour: 0, 52 | minute: 0, 53 | second: 0, 54 | }, 55 | { weekStartDay: opt.weekStartDay }, 56 | ); 57 | const endMoment = startMoment.add(1, 'month'); 58 | 59 | return new Chrono.ParsedResult({ 60 | ref, 61 | text: match[0], 62 | index: match.index, 63 | tags: { myCustomParser: true }, 64 | start: { 65 | ...chronoDateStructFromMoment(startMoment), 66 | timezone, 67 | }, 68 | end: { 69 | ...chronoDateStructFromMoment(endMoment), 70 | timezone, 71 | }, 72 | }); 73 | }; 74 | 75 | export default parser; 76 | -------------------------------------------------------------------------------- /packages/date-parser/src/constants.js: -------------------------------------------------------------------------------- 1 | export const DATE_UNIT_LEVELS = { 2 | year: 0, 3 | month: 1, 4 | week: 2, 5 | day: 3, 6 | hour: 4, 7 | minute: 5, 8 | second: 6, 9 | }; 10 | 11 | export const WEEKDAYS = { 12 | Sunday: 'sunday', 13 | Monday: 'monday', 14 | Tuesday: 'tuesday', 15 | Wednesday: 'wednesday', 16 | Thursday: 'thursday', 17 | Friday: 'friday', 18 | Saturday: 'saturday', 19 | }; 20 | 21 | export const WEEKDAYS_MAP = { 22 | sun: 0, 23 | [WEEKDAYS.Sunday]: 0, 24 | mon: 1, 25 | [WEEKDAYS.Monday]: 1, 26 | tue: 2, 27 | [WEEKDAYS.Tuesday]: 2, 28 | wed: 3, 29 | [WEEKDAYS.Wednesday]: 3, 30 | thu: 4, 31 | [WEEKDAYS.Thursday]: 4, 32 | fri: 5, 33 | [WEEKDAYS.Friday]: 5, 34 | sat: 6, 35 | [WEEKDAYS.Saturday]: 6, 36 | }; 37 | 38 | /** 39 | * @enum {String} 40 | */ 41 | export const OUTPUT_TYPES = { 42 | // v1 only 43 | parsed_component: 'parsed_component', // deprecated in v3 44 | 45 | // v1 and v3 46 | // Note these behavior changes in v3 47 | // 48 | // * timestamp: v1 returns UTC, while in v3 it returns the timestamp with correct timezone offset 49 | // E.g. v1: 2021-12-01 16:00:00Z, v3: 2021-12-02 00:00:00+08:00 50 | // 51 | // * date: same as v3 52 | // 53 | // * raw: v1 returns Chrono Parsed component, v3 returns the `Result` class itself 54 | // 55 | date: 'date', 56 | timestamp: 'timestamp', 57 | raw: 'raw', 58 | 59 | // v3 only 60 | timestamp_utc: 'timestamp_utc', 61 | luxon: 'luxon', 62 | }; 63 | 64 | export const DATE_RANGE_KEYWORDS = { 65 | rangeEndInclusive: ['-', 'to'], 66 | rangeEndExclusive: ['till', 'until'], 67 | }; 68 | 69 | export const DATE_RANGE_PATTERNS = { 70 | rangeEndInclusive: new RegExp(`(.+) (${DATE_RANGE_KEYWORDS.rangeEndInclusive.join('|')}) (.+)`, 'i'), 71 | rangeEndExclusive: new RegExp(`(.+) (${DATE_RANGE_KEYWORDS.rangeEndExclusive.join('|')}) (.+)`, 'i'), 72 | }; 73 | 74 | export const PARSER_VERSION_1 = 1; 75 | export const PARSER_VERSION_3 = 3; 76 | export const DATE_STRING_CHARACTER_LIMIT = 200; 77 | -------------------------------------------------------------------------------- /packages/date-parser/src/parsers/xAgo.js: -------------------------------------------------------------------------------- 1 | import Chrono from 'chrono-node'; 2 | import dateStructFromDate from '../helpers/dateStructFromDate'; 3 | import truncateDateStruct from '../helpers/truncateDateStruct'; 4 | import momentFromStruct from '../helpers/momentFromStruct'; 5 | import chronoDateStructFromMoment from '../helpers/chronoDateStructFromMoment'; 6 | 7 | const parser = new Chrono.Parser(); 8 | 9 | parser.pattern = () => { 10 | /* eslint-disable-next-line max-len */ 11 | return new RegExp('(exact(?:ly)? )?(\\d+) (year|month|week|day|hour|minute|second)s? (ago|from now)( for \\d+ (?:year|month|week|day|hour|minute|second)s?)?', 'i'); 12 | }; 13 | 14 | /** 15 | * @param {String} text 16 | * @param {Date} ref 17 | * @param {Array} match 18 | */ 19 | parser.extract = (text, ref, match, opt) => { 20 | const exact = !!match[1]; 21 | const dateUnit = match[3].toLowerCase(); 22 | const isPast = match[4].toLowerCase() !== 'from now'; 23 | const value = parseInt(match[2]) * (isPast ? -1 : 1); 24 | const duration = match[5]; 25 | 26 | let refDateStruct = dateStructFromDate(ref); 27 | if (!exact) { 28 | refDateStruct = truncateDateStruct(refDateStruct, dateUnit); 29 | } 30 | let startMoment = momentFromStruct(refDateStruct, { weekStartDay: opt.weekStartDay }); 31 | startMoment = startMoment.add(value, dateUnit); 32 | 33 | let endMoment = startMoment.clone(); 34 | if (duration) { 35 | const [durationValue, durationDateUnit] = duration.replace(' for ', '').split(' '); 36 | endMoment = endMoment.add(parseInt(durationValue), durationDateUnit.toLowerCase()); 37 | } else if (exact) { 38 | endMoment = endMoment.add(1, 'second'); 39 | } else { 40 | endMoment = endMoment.add(1, dateUnit); 41 | } 42 | 43 | return new Chrono.ParsedResult({ 44 | ref, 45 | text: match[0], 46 | tags: { xAgoParser: true }, 47 | index: match.index, 48 | start: chronoDateStructFromMoment(startMoment), 49 | end: chronoDateStructFromMoment(endMoment), 50 | }); 51 | }; 52 | 53 | export default parser; 54 | -------------------------------------------------------------------------------- /packages/date-parser/src/parsers/weekday.js: -------------------------------------------------------------------------------- 1 | import Chrono from 'chrono-node'; 2 | import dateStructFromDate from '../helpers/dateStructFromDate'; 3 | import truncateDateStruct from '../helpers/truncateDateStruct'; 4 | import momentFromStruct from '../helpers/momentFromStruct'; 5 | import chronoDateStructFromMoment from '../helpers/chronoDateStructFromMoment'; 6 | import pluralize from '../helpers/pluralize'; 7 | import { WEEKDAYS_MAP } from '../constants'; 8 | 9 | const parser = new Chrono.Parser(); 10 | 11 | parser.pattern = () => { 12 | /* eslint-disable-next-line max-len */ 13 | return new RegExp(`(${Object.keys(WEEKDAYS_MAP).join('|')}) (last|this|current|next)( \\d+)? weeks?`, 'i'); 14 | }; 15 | 16 | /** 17 | * @param {String} text 18 | * @param {Date} ref 19 | * @param {Array} match 20 | * @param {Object} opt 21 | */ 22 | parser.extract = (text, ref, match, opt) => { 23 | const { weekStartDay } = opt; 24 | const weekday = match[1].toLowerCase(); 25 | const modifier = match[2].toLowerCase(); 26 | let value; 27 | if (modifier === 'last') { 28 | value = parseInt(match[3] || 1) * -1; 29 | } else if (modifier === 'next') { 30 | value = parseInt(match[3] || 1); 31 | } else { 32 | value = 0; 33 | } 34 | 35 | const refDateStruct = truncateDateStruct(dateStructFromDate(ref), 'day'); 36 | let startMoment = momentFromStruct(refDateStruct, { weekStartDay }); 37 | startMoment = startMoment.add(value, 'week'); 38 | startMoment = startMoment.weekday((7 + WEEKDAYS_MAP[weekday] - weekStartDay) % 7); 39 | 40 | const endMoment = startMoment.add(1, 'day'); 41 | 42 | return new Chrono.ParsedResult({ 43 | ref, 44 | text: match[0], 45 | // NOTE: just keeping normalized_text here for possible future UX improvement, it is not actually kept in Chrono.ParsedResult 46 | normalized_text: `${match[1]} ${match[2]}${value ? match[3] : ''} ${pluralize('week', value || 1)}`, 47 | index: match.index, 48 | tags: { weekdayParser: true }, 49 | start: chronoDateStructFromMoment(startMoment), 50 | end: chronoDateStructFromMoment(endMoment), 51 | }); 52 | }; 53 | 54 | export default parser; 55 | -------------------------------------------------------------------------------- /packages/date-parser/src/parsers/v3/xAgo.js: -------------------------------------------------------------------------------- 1 | import Chrono from 'chrono-node'; 2 | import truncateDateStruct from '../../helpers/truncateDateStruct'; 3 | import dateStructFromLuxon from '../../helpers/dateStructFromLuxon'; 4 | import luxonFromStruct from '../../helpers/luxonFromStruct'; 5 | import toPluralLuxonUnit from '../../helpers/toPluralLuxonUnit'; 6 | 7 | const parser = new Chrono.Parser(); 8 | 9 | parser.pattern = () => { 10 | /* eslint-disable-next-line max-len */ 11 | return new RegExp('(exact(?:ly)? )?(\\d+) (year|month|week|day|hour|minute|second)s? (ago|from now)( for \\d+ (?:year|month|week|day|hour|minute|second)s?)?', 'i'); 12 | }; 13 | 14 | /** 15 | * @param {String} text 16 | * @param {Date} ref 17 | * @param {Array} match 18 | */ 19 | parser.extract = (text, ref, match, opt) => { 20 | const exact = !!match[1]; 21 | const dateUnit = match[3].toLowerCase(); 22 | const isPast = match[4].toLowerCase() !== 'from now'; 23 | const value = parseInt(match[2]) * (isPast ? -1 : 1); 24 | const duration = match[5]; 25 | 26 | const { luxonRefInTargetTz } = opt; 27 | 28 | let refDateStruct = dateStructFromLuxon(luxonRefInTargetTz); 29 | 30 | if (!exact) { 31 | refDateStruct = truncateDateStruct(refDateStruct, dateUnit, false); 32 | } 33 | 34 | let startLuxon = luxonFromStruct(refDateStruct); 35 | const luxonUnits = toPluralLuxonUnit(dateUnit); 36 | startLuxon = startLuxon.plus({ [luxonUnits]: value }); 37 | 38 | let endLuxon = startLuxon; 39 | 40 | if (duration) { 41 | const [durationValue, durationDateUnit] = duration.replace(' for ', '').split(' '); 42 | const durationUnits = toPluralLuxonUnit(durationDateUnit.toLowerCase()); 43 | endLuxon = endLuxon.plus({ [durationUnits]: parseInt(durationValue) }); 44 | } else if (exact) { 45 | endLuxon = endLuxon.plus({ seconds: 1 }); 46 | } else { 47 | endLuxon = endLuxon.plus({ [luxonUnits]: 1 }); 48 | } 49 | 50 | return new Chrono.ParsedResult({ 51 | ref, 52 | text: match[0], 53 | tags: { xAgoParser: true }, 54 | index: match.index, 55 | start: dateStructFromLuxon(startLuxon), 56 | end: dateStructFromLuxon(endLuxon), 57 | }); 58 | }; 59 | 60 | export default parser; 61 | -------------------------------------------------------------------------------- /packages/date-parser/src/optionsV3.js: -------------------------------------------------------------------------------- 1 | import ChronoNode from 'chrono-node'; 2 | 3 | // Common parsers & refiners for both V1 and V3 4 | import ambiguousWeekdayGuard from './parsers/guards/ambiguousWeekday'; 5 | import yearParser from './parsers/year'; 6 | import yearMonthParser from './parsers/yearMonth'; 7 | import ambiguityRefiner from './refiners/ambiguity'; 8 | 9 | import constantsParser from './parsers/v3/constants'; 10 | import todayParser from './parsers/v3/today'; 11 | import weekdayParser from './parsers/v3/weekday'; 12 | import xAgoParser from './parsers/v3/xAgo'; 13 | import lastXParser from './parsers/v3/lastX'; 14 | 15 | import implier from './refiners/v3/implier'; 16 | import timezoneRefiner from './refiners/v3/timezone'; 17 | 18 | import { wrapAbsoluteChronoParser, wrapRelativeChronoParser } from './parsers/v3/wrapChronoParser'; 19 | 20 | const { parser, refiner } = ChronoNode; 21 | const parserConfig = { strict: true }; 22 | 23 | export default { 24 | parsers: [ 25 | ambiguousWeekdayGuard, 26 | 27 | constantsParser, 28 | todayParser, 29 | yearParser, 30 | weekdayParser, 31 | xAgoParser, 32 | lastXParser, 33 | yearMonthParser, 34 | 35 | wrapAbsoluteChronoParser(parser.ENISOFormatParser, parserConfig), 36 | wrapRelativeChronoParser(parser.ENDeadlineFormatParser, parserConfig), 37 | wrapAbsoluteChronoParser(parser.ENMonthNameLittleEndianParser, parserConfig), 38 | wrapAbsoluteChronoParser(parser.ENMonthNameMiddleEndianParser, parserConfig), 39 | wrapAbsoluteChronoParser(parser.ENMonthNameParser, parserConfig), 40 | wrapAbsoluteChronoParser(parser.ENSlashDateFormatParser, parserConfig), 41 | wrapAbsoluteChronoParser(parser.ENSlashDateFormatStartWithYearParser, parserConfig), 42 | wrapAbsoluteChronoParser(parser.ENSlashMonthFormatParser, parserConfig), 43 | wrapRelativeChronoParser(parser.ENTimeExpressionParser, parserConfig), 44 | ], 45 | refiners: [ 46 | new refiner.OverlapRemovalRefiner(), 47 | new refiner.ForwardDateRefiner(), 48 | 49 | // English 50 | new refiner.ENMergeDateTimeRefiner(), 51 | new refiner.ENMergeDateRangeRefiner(), 52 | new refiner.ENPrioritizeSpecificDateRefiner(), 53 | 54 | timezoneRefiner, 55 | implier, 56 | ambiguityRefiner, 57 | ], 58 | }; 59 | -------------------------------------------------------------------------------- /packages/date-parser/src/parsers/v3/weekday.js: -------------------------------------------------------------------------------- 1 | import Chrono from 'chrono-node'; 2 | import truncateDateStruct from '../../helpers/truncateDateStruct'; 3 | import pluralize from '../../helpers/pluralize'; 4 | import { WEEKDAYS_MAP } from '../../constants'; 5 | import dateStructFromLuxon from '../../helpers/dateStructFromLuxon'; 6 | import luxonFromStruct from '../../helpers/luxonFromStruct'; 7 | import { startOfCustom } from '../../helpers/startEndOfCustom'; 8 | import weekdayIdxFromLuxon from '../../helpers/weekdayIdxFromLuxon'; 9 | 10 | const parser = new Chrono.Parser(); 11 | 12 | const daysBetweeen = (startOfWeek, weekday) => { 13 | const fromWeekdayIdx = weekdayIdxFromLuxon(startOfWeek); 14 | const toWeekdayIdx = WEEKDAYS_MAP[weekday]; 15 | 16 | if (fromWeekdayIdx <= toWeekdayIdx) { return toWeekdayIdx - fromWeekdayIdx; } 17 | 18 | return toWeekdayIdx + 7 - fromWeekdayIdx; 19 | }; 20 | 21 | parser.pattern = () => { 22 | /* eslint-disable-next-line max-len */ 23 | return new RegExp(`(${Object.keys(WEEKDAYS_MAP).join('|')}) (last|this|current|next)( \\d+)? weeks?`, 'i'); 24 | }; 25 | 26 | /** 27 | * @param {String} text 28 | * @param {Date} ref 29 | * @param {Array} match 30 | * @param {Object} opt 31 | */ 32 | parser.extract = (text, ref, match, opt) => { 33 | const { luxonRefInTargetTz, weekStartDay } = opt; 34 | 35 | const weekday = match[1].toLowerCase(); 36 | const modifier = match[2].toLowerCase(); 37 | let value; 38 | if (modifier === 'last') { 39 | value = parseInt(match[3] || 1) * -1; 40 | } else if (modifier === 'next') { 41 | value = parseInt(match[3] || 1); 42 | } else { 43 | value = 0; 44 | } 45 | 46 | const truncatedStruct = truncateDateStruct(dateStructFromLuxon(luxonRefInTargetTz), 'day', false); 47 | const truncatedLuxon = luxonFromStruct(truncatedStruct); 48 | 49 | const someWhereInTheWeek = truncatedLuxon.plus({ weeks: value }); 50 | const startOfWeek = startOfCustom(someWhereInTheWeek, 'week', weekStartDay); 51 | 52 | const range = daysBetweeen(startOfWeek, weekday); 53 | 54 | const startDate = startOfWeek.plus({ days: range }); 55 | const endDate = startDate.plus({ days: 1 }); 56 | 57 | return new Chrono.ParsedResult({ 58 | ref, 59 | text: match[0], 60 | // NOTE: just keeping normalized_text here for possible future UX improvement, it is not actually kept in Chrono.ParsedResult 61 | normalized_text: `${match[1]} ${match[2]}${value ? match[3] : ''} ${pluralize('week', value || 1)}`, 62 | index: match.index, 63 | tags: { weekdayParser: true }, 64 | start: dateStructFromLuxon(startDate), 65 | end: dateStructFromLuxon(endDate), 66 | }); 67 | }; 68 | 69 | export default parser; 70 | -------------------------------------------------------------------------------- /packages/date-parser/src/result.js: -------------------------------------------------------------------------------- 1 | import { merge } from 'lodash'; 2 | import luxonFromChronoStruct from './helpers/luxonFromChronoStruct'; 3 | 4 | export default class Result { 5 | /** 6 | * @param {Date} ref 7 | * @param {Number} index 8 | * @param {String} text 9 | * @param {Chrono.ParsingComponents} start 10 | * @param {Chrono.ParsingComponents} end 11 | * @param {String} weekStartDay 12 | */ 13 | constructor ({ 14 | ref, index, text, start, end, weekStartDay, 15 | }) { 16 | this.ref = ref; 17 | this.index = index; 18 | this.text = text; 19 | this.start = start; 20 | this.end = end; 21 | this.weekStartDay = weekStartDay; 22 | } 23 | 24 | toObject () { 25 | return { 26 | ref: this.ref, 27 | index: this.index, 28 | text: this.text, 29 | start: this.start, 30 | end: this.end, 31 | weekStartDay: this.weekStartDay, 32 | rawResult: { 33 | start: this.start ? luxonFromChronoStruct(this.start).toISO() : null, 34 | end: this.end ? luxonFromChronoStruct(this.end).toISO() : null, 35 | }, 36 | }; 37 | } 38 | 39 | asLuxon () { 40 | return merge( 41 | this.toObject(), { 42 | start: this.start ? luxonFromChronoStruct(this.start) : null, 43 | end: this.end ? luxonFromChronoStruct(this.end) : null, 44 | }, 45 | ); 46 | } 47 | 48 | // Note that the luxon instance contains the original timezone 49 | // The date here is formatted from that instance, so it's still implicit in that timezone 50 | asDate () { 51 | const luxonResult = this.asLuxon(); 52 | return merge( 53 | luxonResult, { 54 | start: luxonResult.start ? luxonResult.start.toFormat('yyyy-MM-dd') : null, 55 | end: luxonResult.end ? luxonResult.end.toFormat('yyyy-MM-dd') : null, 56 | }, 57 | ); 58 | } 59 | 60 | // Convert the timezone to UTC then return the values, hence, 61 | // the original timezone offset is lost 62 | asTimestampUtc () { 63 | const luxonResult = this.asLuxon(); 64 | return merge( 65 | luxonResult, { 66 | start: luxonResult.start ? luxonResult.start.setZone('Etc/UTC').toISO() : null, 67 | end: luxonResult.end ? luxonResult.end.setZone('Etc/UTC').toISO() : null, 68 | }, 69 | ); 70 | } 71 | 72 | // This reserves the timezone offset 73 | // E.g. 2017-04-20T11:32:00.000-04:00 74 | asTimestamp () { 75 | const luxonResult = this.asLuxon(); 76 | return merge( 77 | luxonResult, { 78 | start: luxonResult.start ? luxonResult.start.toISO() : null, 79 | end: luxonResult.end ? luxonResult.end.toISO() : null, 80 | }, 81 | ); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /packages/date-parser/src/parsers/lastX.js: -------------------------------------------------------------------------------- 1 | import Chrono from 'chrono-node'; 2 | import truncateDateStruct from '../helpers/truncateDateStruct'; 3 | import dateStructFromDate from '../helpers/dateStructFromDate'; 4 | import momentFromStruct from '../helpers/momentFromStruct'; 5 | import chronoDateStructFromMoment from '../helpers/chronoDateStructFromMoment'; 6 | import isTimeUnit from '../helpers/isTimeUnit'; 7 | import pluralize from '../helpers/pluralize'; 8 | 9 | const parser = new Chrono.Parser(); 10 | 11 | parser.pattern = () => { 12 | return new RegExp('(last|next|this|current)( \\d+)? (year|quarter|month|week|day|hour|minute|second)s?( (?:begin|end))?', 'i'); 13 | }; 14 | 15 | /** 16 | * @param {String} text 17 | * @param {Date} ref 18 | * @param {Array} match 19 | * @param {Object} opt 20 | */ 21 | parser.extract = (text, ref, match, opt) => { 22 | const { weekStartDay } = opt; 23 | const modifier = match[1].toLowerCase(); 24 | const value = modifier === 'this' ? 0 : parseInt((match[2] || '1').trim()); 25 | const dateUnit = match[3].toLowerCase(); 26 | const pointOfTime = (match[4] || '').trim(); 27 | 28 | const refDateStruct = truncateDateStruct(dateStructFromDate(ref), dateUnit); 29 | let startMoment = momentFromStruct(refDateStruct, { weekStartDay }); 30 | let endMoment = startMoment.clone(); 31 | 32 | // Set range according to past/future relativity 33 | if (modifier === 'last') { 34 | startMoment = startMoment.subtract(value, dateUnit); 35 | endMoment = endMoment.subtract(1, dateUnit); 36 | } else if (modifier === 'next') { 37 | endMoment = endMoment.add(value, dateUnit); 38 | startMoment = startMoment.add(1, dateUnit); 39 | } 40 | 41 | // Push start, end to start, end of time period 42 | startMoment = startMoment.startOf(dateUnit); 43 | endMoment = endMoment.endOf(dateUnit).add(1, 'second').millisecond(0); 44 | 45 | // Set to point of time if specified 46 | if (pointOfTime === 'begin') { 47 | endMoment = startMoment.add(1, isTimeUnit(dateUnit) ? 'second' : 'day'); 48 | } else if (pointOfTime === 'end') { 49 | startMoment = endMoment.subtract(1, isTimeUnit(dateUnit) ? 'second' : 'day'); 50 | } 51 | 52 | return new Chrono.ParsedResult({ 53 | ref, 54 | text: match[0], 55 | // NOTE: just keeping normalized_text here for possible future UX improvement, it is not actually kept in Chrono.ParsedResult 56 | normalized_text: `${match[1]}${value ? match[2] : ''} ${pluralize(match[3], value || 1)}${match[4] || ''}`, 57 | index: match.index, 58 | tags: { lastXParser: true }, 59 | start: chronoDateStructFromMoment(startMoment), 60 | end: chronoDateStructFromMoment(endMoment), 61 | }); 62 | }; 63 | 64 | export default parser; 65 | -------------------------------------------------------------------------------- /packages/date-parser/src/parsers/v3/lastX.js: -------------------------------------------------------------------------------- 1 | import Chrono from 'chrono-node'; 2 | import truncateDateStruct from '../../helpers/truncateDateStruct'; 3 | import isTimeUnit from '../../helpers/isTimeUnit'; 4 | import pluralize from '../../helpers/pluralize'; 5 | import dateStructFromLuxon from '../../helpers/dateStructFromLuxon'; 6 | import luxonFromStruct from '../../helpers/luxonFromStruct'; 7 | import toPluralLuxonUnit from '../../helpers/toPluralLuxonUnit'; 8 | import { startOfCustom, endOfCustom } from '../../helpers/startEndOfCustom'; 9 | 10 | const parser = new Chrono.Parser(); 11 | 12 | parser.pattern = () => { 13 | return new RegExp('(last|next|this|current)( \\d+)? (year|quarter|month|week|day|hour|minute|second)s?( (?:begin|end))?', 'i'); 14 | }; 15 | 16 | /** 17 | * @param {String} text 18 | * @param {Date} ref 19 | * @param {Array} match 20 | * @param {Object} opt 21 | */ 22 | parser.extract = (text, ref, match, opt) => { 23 | const { luxonRefInTargetTz, weekStartDay } = opt; 24 | 25 | const modifier = match[1].toLowerCase(); 26 | const value = modifier === 'this' ? 0 : parseInt((match[2] || '1').trim()); 27 | const dateUnit = match[3].toLowerCase(); 28 | const pointOfTime = (match[4] || '').trim(); 29 | 30 | const refDateStruct = truncateDateStruct(dateStructFromLuxon(luxonRefInTargetTz), dateUnit, false); 31 | 32 | let startLuxon = luxonFromStruct(refDateStruct); 33 | let endLuxon = startLuxon; 34 | 35 | // Set range according to past/future relativity 36 | const luxonUnits = toPluralLuxonUnit(dateUnit); 37 | if (modifier === 'last') { 38 | startLuxon = startLuxon.minus({ [luxonUnits]: value }); 39 | endLuxon = endLuxon.minus({ [luxonUnits]: 1 }); 40 | } else if (modifier === 'next') { 41 | endLuxon = endLuxon.plus({ [luxonUnits]: value }); 42 | startLuxon = startLuxon.plus({ [luxonUnits]: 1 }); 43 | } 44 | 45 | // Push start, end to start, end of time period 46 | startLuxon = startOfCustom(startLuxon, dateUnit, weekStartDay); 47 | endLuxon = endOfCustom(endLuxon, dateUnit, weekStartDay).plus({ seconds: 1 }).set({ millisecond: 0 }); 48 | 49 | // Set to point of time if specified 50 | if (pointOfTime === 'begin') { 51 | endLuxon = isTimeUnit(dateUnit) ? startLuxon.plus({ seconds: 1 }) : startLuxon.plus({ days: 1 }); 52 | } else if (pointOfTime === 'end') { 53 | startLuxon = isTimeUnit(dateUnit) ? endLuxon.minus({ seconds: 1 }) : endLuxon.minus({ days: 1 }); 54 | } 55 | 56 | return new Chrono.ParsedResult({ 57 | ref, 58 | text: match[0], 59 | // NOTE: just keeping normalized_text here for possible future UX improvement, it is not actually kept in Chrono.ParsedResult 60 | normalized_text: `${match[1]}${value ? match[2] : ''} ${pluralize(match[3], value || 1)}${match[4] || ''}`, 61 | index: match.index, 62 | tags: { lastXParser: true }, 63 | start: dateStructFromLuxon(startLuxon), 64 | end: dateStructFromLuxon(endLuxon), 65 | }); 66 | }; 67 | 68 | export default parser; 69 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '31 2 * * 6' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | -------------------------------------------------------------------------------- /packages/date-parser/README.md: -------------------------------------------------------------------------------- 1 | # `@holistics/date-parser` 2 | 3 | > Holistics (relative) date parser 4 | 5 | ## Usage (v3.x) 6 | ### API 7 | ```javascript 8 | export const parse = (str, ref, { 9 | timezoneRegion = 'Etc/UTC', 10 | output = OUTPUT_TYPES.parsed_component, 11 | weekStartDay = WEEKDAYS.Monday, 12 | parserVersion = 3, 13 | } = {}) 14 | ``` 15 | 16 | Note 17 | - Use `parserVersion = 3` to use the new date parser that supports timezone region 18 | - To use the old API of v2.x, please read the below section of v2.x 19 | - This `parserVersion` flag is to help you gradually migrating to v3.x 20 | 21 | ### Output types: 22 | - date: 2021-12-01 (wallclock time, timezone is implicit) 23 | - timestamp: E.g. 2021-12-02 00:00:00+08:00. The offset here is determined by the timezone region input 24 | - timestamp_utc: same as `timestamp` but the result is converted to UTC, e.g. 2021-12-02 16:00:00+00:00 25 | - raw: return the `Result` class, mostly for internal debugging 26 | - luxon: return a Luxon instance 27 | 28 | 29 | ### Examples 30 | ```javascript 31 | let res = nil 32 | 33 | res = parse('last 2 days', new Date('2019-12-26T02:14:05Z'), { parserVersion: 2, output: 'date', timezoneRegion: 'America/Chicago' }); 34 | expect(res.start).toEqual('2019-12-23'); 35 | expect(res.end).toEqual('2019-12-25'); 36 | 37 | res = parse('last 2 days', new Date('2019-12-26T02:14:05Z'), { parserVersion: 2, output: 'timestamp', timezoneRegion: 'America/Chicago' }); 38 | expect(res.start).toEqual('2019-12-23T00:00:00.000-06:00'); 39 | expect(res.end).toEqual('2019-12-25T00:00:00.000-06:00'); 40 | 41 | res = parse('last 2 days', new Date('2019-12-26T02:14:05Z'), { parserVersion: 2, output: 'timestamp_utc', timezoneRegion: 'America/Chicago' }); 42 | expect(res.start).toEqual('2019-12-23T06:00:00.000+00:00'); 43 | expect(res.end).toEqual('2019-12-25T06:00:00.000+00:00'); 44 | 45 | res = parse('last 2 days', new Date('2019-12-26T02:14:05Z'), { parserVersion: 2, output: 'luxon', timezoneRegion: 'America/Chicago' }); 46 | expect(res.start.toISO()).toEqual('2019-12-23T00:00:00.000-06:00'); 47 | 48 | res = parse('last 2 days', new Date('2019-12-26T02:14:05Z'), { parserVersion: 2, timezoneRegion: 'America/Chicago' }); 49 | expect(res.start).toEqual('2019-12-23T00:00:00.000-06:00'); 50 | ``` 51 | 52 | ## Usage (v2.x) 53 | **Note**: v2.x is still applicable but it will be deprecated. No further changes will be made on the v2.x 54 | 55 | ```javascript 56 | import { parse, OUTPUT_TYPES, WEEKDAYS } from '@holistics/date-parser'; 57 | 58 | const referenceDate = new Date(); 59 | 60 | // Using default options 61 | console.log(parse('yesterday'), referenceDate); 62 | 63 | const { text, ref, start, end } = parse('monday last week', '2019-01-03T03:14:29Z'); 64 | console.log(start.moment().format('YYYY/MM/DD')); 65 | console.log(start.date().toUTCString()); 66 | 67 | // Change the output format 68 | console.log(parse('last week begin'), referenceDate, { output: OUTPUT_TYPES.date }); 69 | console.log(parse('last week end'), referenceDate, { output: OUTPUT_TYPES.timestamp }); 70 | 71 | // Set timezoneOffset (timezoneOffset is 0 by default) 72 | const timezoneOffset = -(new Date().getTimezoneOffset); // should use the actual offset, not the Javascript's reversed offset 73 | console.log(parse('3 days from now'), referenceDate, { timezoneOffset }); 74 | // the following examples demonstrate why timezoneOffset is important 75 | let res; 76 | res = parse('yesterday', '2019-04-11T22:00:00+00:00', { output: OUTPUT_TYPES.date }); 77 | console.log(res.start) // 2019-04-10 78 | console.log(res.end) // 2019-04-11 79 | res = parse('yesterday', '2019-04-12T06:00:00+08:00', { output: OUTPUT_TYPES.date }); 80 | console.log(res.start) // 2019-04-10 81 | console.log(res.end) // 2019-04-11 82 | res = parse('yesterday', '2019-04-11T22:00:00+00:00', { timezoneOffset: 540, output: OUTPUT_TYPES.date }); 83 | console.log(res.start) // 2019-04-11 84 | console.log(res.end) // 2019-04-12 85 | 86 | // Set weekStartDay (weekStartDay is Monday by default) 87 | res = parse('last week begin', '2021-05-10T22:14:05Z', { weekStartDay: WEEKDAYS.Tuesday, output: OUTPUT_TYPES.date }); 88 | console.log(res.start) // 2021-04-27 89 | ``` 90 | 91 | ## Try it out 92 | https://uho5b.csb.app/ 93 | -------------------------------------------------------------------------------- /packages/date-parser/src/parsers/v3/wrapChronoParser.js: -------------------------------------------------------------------------------- 1 | import Chrono from 'chrono-node'; 2 | import { 3 | difference, keys, pick, isNumber, 4 | } from 'lodash'; 5 | import dateStructFromLuxon from '../../helpers/dateStructFromLuxon'; 6 | import luxonFromChronoStruct from '../../helpers/luxonFromChronoStruct'; 7 | 8 | const convertToTimezone = (parsedResult, timezone) => { 9 | // build a UTC luxon from Chrono 10 | const luxon = luxonFromChronoStruct(parsedResult); 11 | const utc = luxon 12 | .plus({ minutes: -parsedResult.get('timezoneOffset') || 0 }) 13 | .setZone('Etc/UTC', { keepLocalTime: true }); 14 | 15 | const timestamptz = utc.setZone(timezone); 16 | 17 | return dateStructFromLuxon(timestamptz); 18 | }; 19 | 20 | const isSetOffset = (parsedComponent) => { 21 | return isNumber(parsedComponent.get('timezoneOffset')); 22 | }; 23 | 24 | // Use the value from dateStruct to build a new Chrono Result 25 | // The structure of the new result follows the existing Chrono Result { knownValues, impliedValues } 26 | const buildChronoResultFrom = (chronoResult, dateStruct) => { 27 | const knownKeys = keys(chronoResult.knownValues); 28 | const knownValues = pick(dateStruct, knownKeys); 29 | const impliedValues = pick(dateStruct, difference(keys(dateStruct), knownKeys)); 30 | return { knownValues, impliedValues }; 31 | }; 32 | 33 | const buildJSDateFromLuxon = (luxon) => { 34 | const wallclockTime = luxon.toISO({ includeOffset: false }); 35 | const adjustedRef = new Date(`${wallclockTime}Z`); 36 | return adjustedRef; 37 | }; 38 | 39 | /** 40 | * Relative Chrono parsers E.g. 3 o'clock 41 | * 42 | */ 43 | const wrapRelativeChronoParser = (ChronoParser, parserConfig) => { 44 | const chronoParser = new ChronoParser(parserConfig); 45 | const wrappedParser = new Chrono.Parser(); 46 | 47 | wrappedParser.pattern = () => { 48 | return chronoParser.pattern(); 49 | }; 50 | 51 | wrappedParser.extract = (text, ref, match, opt) => { 52 | const { luxonRefInTargetTz } = opt; 53 | 54 | /** 55 | * Normally, in our custom parsers, we're using luxonRefInTargetTz as our `ref` which is already in the correct timezone. 56 | * However, Chrono parser is still using a JS Date which is still in UTC. 57 | * So we need convert that JS `ref` to the target timezone before passing into Chrono parsers. 58 | */ 59 | const adjustedRef = buildJSDateFromLuxon(luxonRefInTargetTz); 60 | 61 | const result = chronoParser.extract(text, adjustedRef, match, opt); 62 | if (!result) { return null; } 63 | 64 | // Explicitly set the offset to be 0 to avoid Chrono doing its own timezone conversion 65 | result.start.imply('timezonOffset', 0); 66 | 67 | return result; 68 | }; 69 | 70 | return wrappedParser; 71 | }; 72 | 73 | /** 74 | * Absolute Chrono parsers are parsers that process ISO-like format string. 75 | * E.g. 2021, 2021-11-20, 2021-11-20T12:00:00Z 76 | * 77 | * The Chrono parser returns 2 types of result 78 | * - Timestamp with offset: we want to convert the result into our target timezone to match our common interface 79 | * - Timestamp without offset: we set it to 0 to avoid Chrono auto imply an offset that can cause some timezone bugs 80 | */ 81 | const wrapAbsoluteChronoParser = (ChronoParser, parserConfig) => { 82 | const chronoParser = new ChronoParser(parserConfig); 83 | const originalExtractFunc = chronoParser.extract; 84 | 85 | chronoParser.extract = (text, ref, match, opt) => { 86 | const { timezone } = opt; 87 | 88 | const result = originalExtractFunc(text, ref, match, opt); 89 | if (!result) { return null; } 90 | 91 | if (!isSetOffset(result.start)) { 92 | result.start.imply('timezoneOffset', 0); 93 | return result; 94 | } 95 | 96 | const convertedStruct = convertToTimezone(result.start, timezone); 97 | 98 | const { knownValues, impliedValues } = buildChronoResultFrom(result.start, convertedStruct); 99 | result.start.knownValues = knownValues; 100 | result.start.impliedValues = impliedValues; 101 | 102 | return result; 103 | }; 104 | 105 | return chronoParser; 106 | }; 107 | 108 | export { wrapAbsoluteChronoParser, wrapRelativeChronoParser }; 109 | -------------------------------------------------------------------------------- /packages/date-parser/src/dateParserV1.js: -------------------------------------------------------------------------------- 1 | import ChronoNode from 'chrono-node'; 2 | import _compact from 'lodash/compact'; 3 | 4 | import dayjs from 'dayjs'; 5 | 6 | // NOTE: order is important to make sure chrono-node uses plugin-enabled dayjs 7 | import './initializers/dayjs'; 8 | import './initializers/chrono-node'; 9 | 10 | import options from './options'; 11 | import isValidDate from './helpers/isValidDate'; 12 | import { 13 | WEEKDAYS, 14 | WEEKDAYS_MAP, 15 | OUTPUT_TYPES, 16 | DATE_RANGE_KEYWORDS, 17 | } from './constants'; 18 | import Errors, { InputError } from './errors'; 19 | 20 | import splitInputStr from './helpers/splitInputString'; 21 | import getParsedResultBoundaries from './helpers/getParsedResultBoundaries'; 22 | import exceedLimit from './helpers/checkDateStringCharacterLimit'; 23 | 24 | const chrono = new ChronoNode.Chrono(options); 25 | 26 | /** 27 | * Parse the given date string into Chrono.ParsedResult 28 | * @param {String} str The date string to parse 29 | * @param {String|Date} ref Reference date 30 | * @param {Object} options 31 | * @param {Number} options.timezoneOffset Timezone offset in minutes 32 | * @param {OUTPUT_TYPES} options.output Type of the output dates 33 | * @param {Number} weekStartDay The weekday chosen to be the start of a week. See WEEKDAYS constant for possible values 34 | * @return {ChronoNode.ParsedResult|Array} 35 | */ 36 | export const parse = (str, ref, { timezoneOffset = 0, output = OUTPUT_TYPES.parsed_component, weekStartDay = WEEKDAYS.Monday } = {}) => { 37 | const refDate = new Date(ref); 38 | if (!isValidDate(refDate)) throw new InputError(`Invalid reference date: ${ref}`); 39 | if (exceedLimit(str)) throw new InputError('Date value exceeds limit of 200 characters'); 40 | 41 | /* eslint-disable-next-line no-param-reassign */ 42 | timezoneOffset = parseInt(timezoneOffset); 43 | if (Number.isNaN(timezoneOffset)) throw new InputError(`Invalid timezoneOffset: ${timezoneOffset}`); 44 | 45 | if (!(weekStartDay in WEEKDAYS_MAP)) throw new InputError(`Invalid weekStartDay: ${weekStartDay}. See exported constant WEEKDAYS for valid values`); 46 | /* eslint-disable-next-line no-param-reassign */ 47 | weekStartDay = WEEKDAYS_MAP[weekStartDay]; 48 | 49 | // Adjust refDate by timezoneOffset 50 | let refMoment = dayjs.utc(refDate); 51 | refMoment = refMoment.add(timezoneOffset, 'minute'); 52 | const refDateAdjustedByTz = refMoment.toDate(); 53 | 54 | const splittedInput = splitInputStr(str); 55 | const { parts, rangeSeparator } = splittedInput; 56 | let { isRange, isRangeEndInclusive } = splittedInput; 57 | 58 | const parsedResults = _compact(parts.map(part => chrono.parse(part, refDateAdjustedByTz, { timezoneOffset, weekStartDay })[0])); 59 | 60 | if (output === OUTPUT_TYPES.raw) return parsedResults; 61 | 62 | if (!parsedResults[0]) return null; 63 | if (parsedResults.length === 1) { 64 | isRange = false; 65 | isRangeEndInclusive = true; 66 | } 67 | 68 | const { first, last, hasOrderChanged } = getParsedResultBoundaries(parsedResults, isRangeEndInclusive); 69 | if (hasOrderChanged && !isRangeEndInclusive) { 70 | throw new InputError(`Start date must be before end date when using end-exclusive syntax (${DATE_RANGE_KEYWORDS.rangeEndExclusive})`); 71 | } 72 | 73 | const result = new ChronoNode.ParsedResult({ 74 | ref: refDate, 75 | index: first.index, 76 | tags: { ...first.tags, ...last.tags }, 77 | text: isRange ? `${first.text} ${rangeSeparator} ${last.text}` : first.text, 78 | }); 79 | result.start = first.start.clone(); 80 | result.end = isRangeEndInclusive ? last.end.clone() : last.start.clone(); 81 | 82 | if (output === OUTPUT_TYPES.date) { 83 | result.start = result.start.moment().format('YYYY-MM-DD'); 84 | result.end = result.end.moment().format('YYYY-MM-DD'); 85 | } else if (output === OUTPUT_TYPES.timestamp) { 86 | result.start = result.start.date().toISOString(); 87 | result.end = result.end.date().toISOString(); 88 | } 89 | 90 | return result; 91 | }; 92 | 93 | export { WEEKDAYS, OUTPUT_TYPES } from './constants'; 94 | export { default as Errors } from './errors'; 95 | 96 | export default { 97 | parse, 98 | WEEKDAYS, 99 | OUTPUT_TYPES, 100 | Errors, 101 | }; 102 | -------------------------------------------------------------------------------- /packages/date-parser/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | # [3.3.0](https://github.com/holistics/js/compare/@holistics/date-parser@3.2.0...@holistics/date-parser@3.3.0) (2023-10-13) 7 | 8 | 9 | ### Security 10 | 11 | * Add limit check for string input ([43a8c52](https://github.com/holistics/js/pull/50/commits/43a8c527e2a517d20b26ad121e4eb65ca8bfea59)) 12 | 13 | 14 | 15 | 16 | 17 | # [3.2.0](https://github.com/holistics/js/compare/@holistics/date-parser@3.1.2...@holistics/date-parser@3.2.0) (2023-08-03) 18 | 19 | 20 | ### Features 21 | 22 | * **yarn:** bump pkg from 4.4.2 to 5.8.1 ([b62bd8c](https://github.com/holistics/js/commit/b62bd8cfbb1436c6bcaefbae5789cb7594f99e98)) 23 | * year month parser ([337584e](https://github.com/holistics/js/commit/337584e2eadebf83d7096dc44e9e149f54ae9666)) 24 | 25 | 26 | 27 | 28 | 29 | ## [3.1.2](https://github.com/holistics/js/compare/@holistics/date-parser@3.0.2...@holistics/date-parser@3.1.2) (2023-05-30) 30 | 31 | * chore(date-parser): upgrade luxon to 3.3.0 to fix ReDoS issue ([95098fd](https://github.com/holistics/js/commit/95098fd50e73efdcf01a31ee1088ae0bdc135570)) 32 | 33 | 34 | 35 | 36 | 37 | ## [3.1.1](https://github.com/holistics/js/compare/@holistics/date-parser@3.0.2...@holistics/date-parser@3.1.1) (2023-05-15) 38 | 39 | **Note:** Version bump only for package @holistics/date-parser 40 | 41 | 42 | 43 | 44 | 45 | # [3.1.0](https://github.com/holistics/js/compare/@holistics/date-parser@3.0.2...@holistics/date-parser@3.1.0) (2023-05-08) 46 | 47 | 48 | ### Add raw results 49 | 50 | * Add raw results to response 51 | 52 | 53 | 54 | 55 | 56 | ## [3.0.2](https://github.com/holistics/js/compare/@holistics/date-parser@3.0.1...@holistics/date-parser@3.0.2) (2021-11-29) 57 | 58 | ### Bug Fixes 59 | * Fix DST issues on Chrono default parsers https://github.com/holistics/js/pull/16 60 | 61 | 62 | ## [3.0.1](https://github.com/holistics/js/compare/@holistics/date-parser@3.0.0...@holistics/date-parser@3.0.1) (2021-11-05) 63 | 64 | ### Bug Fixes 65 | 66 | https://github.com/holistics/js/pull/15 67 | * Bug 1: doesn't handle null result 68 | * Bug 2: Inconsistent behavior of { zone } options in the browser. When calling with fromISO the zone is set to the DateTime instance, but when calling with fromObject the system zone (browser's zone) is used. This could be a bug of Luxon 69 | * Bug 3: using weekdayLong is affected by locale, switch to use index 70 | 71 | 72 | # [3.0.0](https://github.com/holistics/js/compare/@holistics/date-parser@2.9.0...@holistics/date-parser@3.0.0) (2021-10-29) 73 | ### Features 74 | * V3 date parser including 75 | * Use timezone region instead of offset 76 | * Change to luxon to handle date-time math (previously dayjs) 77 | * The timezone offset of the output follows the timezone region, included ISO cases. For example: parsing 2021-01-02 00:00:00+00:00 would return 2021-01-01 17:00:00+07:00 with timezone region Singapore 78 | * Week Start Day: use our own logic to handle instead of using dayjs 79 | 80 | # [2.11.0](https://github.com/holistics/js/compare/@holistics/date-parser@2.9.0...@holistics/date-parser@2.11.0) (2021-08-30) 81 | 82 | 83 | ### Bug Fixes 84 | 85 | * handle upper characters ([bc6427e](https://github.com/holistics/js/commit/bc6427e9836aa0b80b5b3a6b61b82debca52f0df)) 86 | 87 | 88 | ### Features 89 | 90 | * support 'current' keyword ([e913f3a](https://github.com/holistics/js/commit/e913f3a4d74ff0823969aaa58bdf3c082fb71427)) 91 | 92 | 93 | 94 | 95 | 96 | # [2.10.0](https://github.com/holistics/js/compare/@holistics/date-parser@2.9.0...@holistics/date-parser@2.10.0) (2021-05-11) 97 | 98 | **Note:** Version bump only for package @holistics/date-parser 99 | 100 | 101 | 102 | 103 | 104 | # [2.9.0](https://github.com/holistics/js/compare/@holistics/date-parser@2.8.1...@holistics/date-parser@2.9.0) (2021-05-11) 105 | 106 | **Note:** Version bump only for package @holistics/date-parser 107 | 108 | 109 | 110 | 111 | 112 | ## [2.8.1](https://github.com/holistics/js/compare/@holistics/date-parser@2.8.0...@holistics/date-parser@2.8.1) (2021-03-16) 113 | 114 | **Note:** Version bump only for package @holistics/date-parser 115 | -------------------------------------------------------------------------------- /packages/date-parser/src/dateParserV3.js: -------------------------------------------------------------------------------- 1 | import _compact from 'lodash/compact'; 2 | // NOTE: order is important to make sure chrono-node uses plugin-enabled dayjs 3 | import ChronoNode from 'chrono-node'; 4 | import './initializers/dayjs'; 5 | import './initializers/chrono-node'; 6 | import options from './optionsV3'; 7 | 8 | import isValidDate from './helpers/isValidDate'; 9 | import TimezoneRegion from './helpers/timezoneRegion'; 10 | import splitInputStr from './helpers/splitInputString'; 11 | import getParsedResultBoundaries from './helpers/getParsedResultBoundaries'; 12 | import exceedLimit from './helpers/checkDateStringCharacterLimit'; 13 | import { InputError } from './errors'; 14 | import { 15 | WEEKDAYS, 16 | WEEKDAYS_MAP, 17 | OUTPUT_TYPES, 18 | DATE_RANGE_KEYWORDS, 19 | PARSER_VERSION_3, 20 | } from './constants'; 21 | import Result from './result'; 22 | import luxonFromJSDate from './helpers/luxonFromJSDate'; 23 | 24 | /** 25 | * @param {String} ref 26 | * @param {String} weekStartDay 27 | * @returns { refDate: Date, wsday: Number} 28 | */ 29 | const parseInputs = (ref, weekStartDay) => { 30 | const jsRefDate = new Date(ref); 31 | if (!isValidDate(jsRefDate)) throw new InputError(`Invalid reference date: ${ref}`); 32 | 33 | if (!(weekStartDay in WEEKDAYS_MAP)) throw new InputError(`Invalid weekStartDay: ${weekStartDay}. See exported constant WEEKDAYS for valid values`); 34 | const wsday = WEEKDAYS_MAP[weekStartDay]; 35 | 36 | return { 37 | jsRefDate, 38 | wsday, 39 | }; 40 | }; 41 | 42 | /** 43 | * 44 | * @param {Chrono.ParsedResult} parsedResults 45 | * @param {String} strInput Original input 46 | * @param {Date} refDate 47 | * @param {String} weekStartDay 48 | * @returns 49 | */ 50 | const buildResult = (parsedResults, strInput, refDate, weekStartDay) => { 51 | if (!parsedResults[0]) return null; 52 | 53 | const { 54 | isRange: inputIsRange, 55 | isRangeEndInclusive: inputIsRangeEndInclusive, 56 | rangeSeparator, 57 | } = splitInputStr(strInput); 58 | 59 | let isRange; 60 | let isRangeEndInclusive; 61 | 62 | if (parsedResults.length === 1) { 63 | isRange = false; 64 | isRangeEndInclusive = true; 65 | } else { 66 | isRange = inputIsRange; 67 | isRangeEndInclusive = inputIsRangeEndInclusive; 68 | } 69 | 70 | const { first, last, hasOrderChanged } = getParsedResultBoundaries(parsedResults, isRangeEndInclusive); 71 | if (hasOrderChanged && !isRangeEndInclusive) { 72 | throw new InputError(`Start date must be before end date when using end-exclusive syntax (${DATE_RANGE_KEYWORDS.rangeEndExclusive})`); 73 | } 74 | 75 | const result = new Result({ 76 | ref: refDate, 77 | index: first.index, 78 | text: isRange ? `${first.text} ${rangeSeparator} ${last.text}` : first.text, 79 | start: first.start.clone(), 80 | end: isRangeEndInclusive ? last.end.clone() : last.start.clone(), 81 | weekStartDay, 82 | }); 83 | 84 | return result; 85 | }; 86 | 87 | /** 88 | * Parse the given date string into Chrono.ParsedResult 89 | * @param {String} str The date string to parse 90 | * @param {String|Date} ref Reference date 91 | * @param {OUTPUT_TYPES} output Type of the output dates 92 | * @param {Number} weekStartDay The weekday chosen to be the start of a week. See WEEKDAYS constant for possible values 93 | * @param {String} timezoneRegion timezone region, only available in V3 parser 94 | * @return {ChronoNode.ParsedResult|Array} 95 | */ 96 | export const parse = (str, ref, { 97 | timezoneRegion = 'Etc/UTC', 98 | output = OUTPUT_TYPES.timestamp, 99 | weekStartDay = WEEKDAYS.Monday, 100 | } = {}) => { 101 | /** 102 | * Inputs parsing and validation 103 | */ 104 | if (exceedLimit(str)) throw new InputError('Date value exceeds limit of 200 characters'); 105 | 106 | const { jsRefDate, wsday } = parseInputs(ref, weekStartDay); 107 | const zone = new TimezoneRegion(timezoneRegion); 108 | const { parts } = splitInputStr(str); 109 | const luxonRefUtc = luxonFromJSDate(jsRefDate); 110 | const luxonRefInTargetTz = luxonRefUtc.setZone(zone.toString()); 111 | /** 112 | * Chrono processing 113 | */ 114 | const chrono = new ChronoNode.Chrono(options); 115 | const parsedResults = _compact( 116 | parts.map( 117 | part => chrono.parse(part, jsRefDate, { 118 | timezone: zone.toString(), 119 | weekStartDay: wsday, 120 | parserVersion: PARSER_VERSION_3, 121 | luxonRefUtc, 122 | luxonRefInTargetTz, 123 | })[0], 124 | ), 125 | ); 126 | 127 | /** 128 | * Parsed result processing 129 | */ 130 | const result = buildResult(parsedResults, str, jsRefDate, weekStartDay); 131 | 132 | if (!result) { return null; } 133 | 134 | switch (output) { 135 | case OUTPUT_TYPES.date: 136 | return result.asDate(); 137 | case OUTPUT_TYPES.timestamp: 138 | return result.asTimestamp(); 139 | case OUTPUT_TYPES.timestamp_utc: 140 | return result.asTimestampUtc(); 141 | case OUTPUT_TYPES.luxon: 142 | return result.asLuxon(); 143 | case OUTPUT_TYPES.raw: 144 | return result; 145 | default: 146 | return result.asTimestamp(); 147 | } 148 | }; 149 | -------------------------------------------------------------------------------- /packages/date-parser/src/dateParserV3.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | parse, WEEKDAYS, 3 | } from './index'; 4 | import { InputError } from './errors'; 5 | import { parse as parseV3 } from './dateParserV3'; 6 | import { PARSER_VERSION_3 } from './constants'; 7 | 8 | describe('Parsing logic', () => { 9 | const defaultOpts = { parserVersion: PARSER_VERSION_3, output: 'raw' }; 10 | 11 | it('throw error when exceed character limit', () => { 12 | expect(() => { 13 | parse(`last week${' '.repeat(1000)}`, new Date('2019-12-26T02:14:05Z'), defaultOpts); 14 | }).toThrow(new InputError('Date value exceeds limit of 200 characters')); 15 | }); 16 | 17 | it('works with lastX format', () => { 18 | let res; 19 | 20 | res = parse('last week', new Date('2019-12-26T02:14:05Z'), defaultOpts); 21 | expect(res.asTimestampUtc().start).toEqual('2019-12-16T00:00:00.000+00:00'); 22 | expect(res.asTimestampUtc().end).toEqual('2019-12-23T00:00:00.000+00:00'); 23 | 24 | res = parse('this week', new Date('2019-12-26T02:14:05Z'), defaultOpts); 25 | expect(res.asTimestampUtc().start).toEqual('2019-12-23T00:00:00.000+00:00'); 26 | expect(res.asTimestampUtc().end).toEqual('2019-12-30T00:00:00.000+00:00'); 27 | 28 | res = parse('last month', new Date('2019-12-26T02:14:05Z'), defaultOpts); 29 | expect(res.asTimestampUtc().start).toEqual('2019-11-01T00:00:00.000+00:00'); 30 | expect(res.asTimestampUtc().end).toEqual('2019-12-01T00:00:00.000+00:00'); 31 | 32 | res = parse('last quarter', new Date('2019-12-26T02:14:05Z'), defaultOpts); 33 | expect(res.asTimestampUtc().start).toEqual('2019-07-01T00:00:00.000+00:00'); 34 | expect(res.asTimestampUtc().end).toEqual('2019-10-01T00:00:00.000+00:00'); 35 | 36 | res = parse('last year', new Date('2020-02-29T02:14:05Z'), defaultOpts); 37 | expect(res.asTimestampUtc().start).toEqual('2019-01-01T00:00:00.000+00:00'); 38 | expect(res.asTimestampUtc().end).toEqual('2020-01-01T00:00:00.000+00:00'); 39 | 40 | res = parse('this month begin', new Date('2019-12-26T02:14:05Z'), defaultOpts); 41 | expect(res.asTimestampUtc().start).toEqual('2019-12-01T00:00:00.000+00:00'); 42 | expect(res.asTimestampUtc().end).toEqual('2019-12-02T00:00:00.000+00:00'); 43 | 44 | res = parse('current month begin', new Date('2019-12-26T02:14:05Z'), defaultOpts); 45 | expect(res.asTimestampUtc().start).toEqual('2019-12-01T00:00:00.000+00:00'); 46 | expect(res.asTimestampUtc().end).toEqual('2019-12-02T00:00:00.000+00:00'); 47 | 48 | res = parse('last month end', new Date('2019-12-26T02:14:05Z'), defaultOpts); 49 | expect(res.asTimestampUtc().start).toEqual('2019-11-30T00:00:00.000+00:00'); 50 | expect(res.asTimestampUtc().end).toEqual('2019-12-01T00:00:00.000+00:00'); 51 | 52 | res = parse('last 2 month', new Date('2019-02-09T02:14:05Z'), defaultOpts); 53 | expect(res.asTimestampUtc().start).toEqual('2018-12-01T00:00:00.000+00:00'); 54 | expect(res.asTimestampUtc().end).toEqual('2019-02-01T00:00:00.000+00:00'); 55 | 56 | // plural date unit 57 | res = parse('last 2 months', new Date('2019-02-09T02:14:05Z'), defaultOpts); 58 | expect(res.asTimestampUtc().start).toEqual('2018-12-01T00:00:00.000+00:00'); 59 | expect(res.asTimestampUtc().end).toEqual('2019-02-01T00:00:00.000+00:00'); 60 | 61 | // uppercase chars 62 | res = parse('LAST 2 mOnth', new Date('2019-02-09T02:14:05Z'), defaultOpts); 63 | expect(res.asTimestampUtc().start).toEqual('2018-12-01T00:00:00.000+00:00'); 64 | expect(res.asTimestampUtc().end).toEqual('2019-02-01T00:00:00.000+00:00'); 65 | 66 | res = parse('last 2 months begin', new Date('2019-02-09T02:14:05Z'), defaultOpts); 67 | expect(res.asTimestampUtc().start).toEqual('2018-12-01T00:00:00.000+00:00'); 68 | expect(res.asTimestampUtc().end).toEqual('2018-12-02T00:00:00.000+00:00'); 69 | 70 | res = parse('last 2 days', new Date('2019-12-26T02:14:05Z'), defaultOpts); 71 | expect(res.asTimestampUtc().start).toEqual('2019-12-24T00:00:00.000+00:00'); 72 | expect(res.asTimestampUtc().end).toEqual('2019-12-26T00:00:00.000+00:00'); 73 | 74 | res = parse('last 2 hours begin', new Date('2019-12-26T01:14:05Z'), defaultOpts); 75 | expect(res.asTimestampUtc().start).toEqual('2019-12-25T23:00:00.000+00:00'); 76 | expect(res.asTimestampUtc().end).toEqual('2019-12-25T23:00:01.000+00:00'); 77 | 78 | res = parse('next 2 minutes end', new Date('2019-12-26T01:14:05Z'), defaultOpts); 79 | expect(res.asTimestampUtc().start).toEqual('2019-12-26T01:16:59.000+00:00'); 80 | expect(res.asTimestampUtc().end).toEqual('2019-12-26T01:17:00.000+00:00'); 81 | 82 | // New tests with tz 83 | 84 | res = parse('last 2 days', new Date('2019-12-26T02:14:05Z'), { ...defaultOpts, timezoneRegion: 'America/Chicago' }); 85 | expect(res.asTimestamp().start).toEqual('2019-12-23T00:00:00.000-06:00'); 86 | expect(res.asTimestamp().end).toEqual('2019-12-25T00:00:00.000-06:00'); 87 | 88 | res = parse('last 1 day', new Date('2021-03-29T01:00:00Z'), { ...defaultOpts, timezoneRegion: 'Europe/Copenhagen' }); 89 | expect(res.asTimestamp().start).toEqual('2021-03-28T00:00:00.000+01:00'); 90 | expect(res.asTimestamp().end).toEqual('2021-03-29T00:00:00.000+02:00'); 91 | 92 | res = parse('last 3 hours', new Date('2021-03-28T01:00:00Z'), { ...defaultOpts, timezoneRegion: 'Europe/Copenhagen' }); 93 | expect(res.asTimestamp().start).toEqual('2021-03-27T23:00:00.000+01:00'); 94 | expect(res.asTimestamp().end).toEqual('2021-03-28T03:00:00.000+02:00'); 95 | 96 | res = parse('last 180 minutes', new Date('2021-03-28T01:00:00Z'), { ...defaultOpts, timezoneRegion: 'Europe/Copenhagen' }); 97 | expect(res.asTimestamp().start).toEqual('2021-03-27T23:00:00.000+01:00'); 98 | expect(res.asTimestamp().end).toEqual('2021-03-28T03:00:00.000+02:00'); 99 | }); 100 | 101 | it('works with xAgo format', () => { 102 | let res; 103 | 104 | res = parse('2 days ago', new Date('2019-12-26T02:14:05Z'), defaultOpts); 105 | expect(res.asTimestampUtc().start).toEqual('2019-12-24T00:00:00.000+00:00'); 106 | expect(res.asTimestampUtc().end).toEqual('2019-12-25T00:00:00.000+00:00'); 107 | 108 | res = parse('exact 2 days ago', new Date('2019-12-26T02:14:05Z'), defaultOpts); 109 | expect(res.asTimestampUtc().start).toEqual('2019-12-24T02:14:05.000+00:00'); 110 | expect(res.asTimestampUtc().end).toEqual('2019-12-24T02:14:06.000+00:00'); 111 | 112 | res = parse('3 weeks from now', new Date('2019-12-26T02:14:05Z'), defaultOpts); 113 | expect(res.asTimestampUtc().start).toEqual('2020-01-16T00:00:00.000+00:00'); 114 | expect(res.asTimestampUtc().end).toEqual('2020-01-23T00:00:00.000+00:00'); 115 | 116 | res = parse('exactly 3 weeks from now', new Date('2019-12-26T02:14:05Z'), defaultOpts); 117 | expect(res.asTimestampUtc().start).toEqual('2020-01-16T02:14:05.000+00:00'); 118 | expect(res.asTimestampUtc().end).toEqual('2020-01-16T02:14:06.000+00:00'); 119 | 120 | res = parse('1 year ago', new Date('2020-02-29T02:14:05Z'), defaultOpts); 121 | expect(res.asTimestampUtc().start).toEqual('2019-01-01T00:00:00.000+00:00'); 122 | expect(res.asTimestampUtc().end).toEqual('2020-01-01T00:00:00.000+00:00'); 123 | 124 | res = parse('exactly 1 year ago', new Date('2020-02-29T02:14:05Z'), defaultOpts); 125 | expect(res.asTimestampUtc().start).toEqual('2019-02-28T02:14:05.000+00:00'); 126 | expect(res.asTimestampUtc().end).toEqual('2019-02-28T02:14:06.000+00:00'); 127 | 128 | res = parse('1 year ago for 5 days', new Date('2020-02-29T02:14:05Z'), defaultOpts); 129 | expect(res.asTimestampUtc().start).toEqual('2019-01-01T00:00:00.000+00:00'); 130 | expect(res.asTimestampUtc().end).toEqual('2019-01-06T00:00:00.000+00:00'); 131 | 132 | res = parse('exactly 1 year ago for 5 days', new Date('2020-02-29T02:14:05Z'), defaultOpts); 133 | expect(res.asTimestampUtc().start).toEqual('2019-02-28T02:14:05.000+00:00'); 134 | expect(res.asTimestampUtc().end).toEqual('2019-03-05T02:14:05.000+00:00'); 135 | 136 | // New tests with tz 137 | 138 | res = parse('2 days ago', new Date('2019-12-26T02:14:05Z'), { ...defaultOpts, timezoneRegion: 'America/Chicago' }); 139 | expect(res.asTimestamp().start).toEqual('2019-12-23T00:00:00.000-06:00'); 140 | expect(res.asTimestamp().end).toEqual('2019-12-24T00:00:00.000-06:00'); 141 | 142 | res = parse('1 year ago for 5 days', new Date('2020-02-29T02:14:05Z'), { ...defaultOpts, timezoneRegion: 'America/Chicago' }); 143 | expect(res.asTimestamp().start).toEqual('2019-01-01T00:00:00.000-06:00'); 144 | expect(res.asTimestamp().end).toEqual('2019-01-06T00:00:00.000-06:00'); 145 | 146 | res = parse('3 hours ago', new Date('2021-03-28T01:00:00Z'), { ...defaultOpts, timezoneRegion: 'Europe/Copenhagen' }); 147 | expect(res.asTimestamp().start).toEqual('2021-03-27T23:00:00.000+01:00'); 148 | expect(res.asTimestamp().end).toEqual('2021-03-28T00:00:00.000+01:00'); 149 | 150 | res = parse('180 minutes ago', new Date('2021-03-28T01:00:00Z'), { ...defaultOpts, timezoneRegion: 'Europe/Copenhagen' }); 151 | expect(res.asTimestamp().start).toEqual('2021-03-27T23:00:00.000+01:00'); 152 | expect(res.asTimestamp().end).toEqual('2021-03-27T23:01:00.000+01:00'); 153 | 154 | res = parse('exact 3 hours ago', new Date('2021-03-28T01:00:00Z'), { ...defaultOpts, timezoneRegion: 'Europe/Copenhagen' }); 155 | expect(res.asTimestamp().start).toEqual('2021-03-27T23:00:00.000+01:00'); 156 | expect(res.asTimestamp().end).toEqual('2021-03-27T23:00:01.000+01:00'); 157 | 158 | res = parse('1 day ago', new Date('2021-03-29T01:00:00Z'), { ...defaultOpts, timezoneRegion: 'Europe/Copenhagen' }); 159 | expect(res.asTimestamp().start).toEqual('2021-03-28T00:00:00.000+01:00'); 160 | expect(res.asTimestamp().end).toEqual('2021-03-29T00:00:00.000+02:00'); 161 | 162 | res = parse('1 day ago', new Date('2021-03-28T01:00:00Z'), { ...defaultOpts, timezoneRegion: 'Europe/Copenhagen' }); 163 | expect(res.asTimestamp().start).toEqual('2021-03-27T00:00:00.000+01:00'); 164 | expect(res.asTimestamp().end).toEqual('2021-03-28T00:00:00.000+01:00'); 165 | }); 166 | 167 | it('works with absolute, both full and partial, dates', () => { 168 | let res; 169 | 170 | res = parse('2019-12-01', new Date('2019-12-26T02:14:05Z'), defaultOpts); 171 | expect(res.asTimestampUtc().start).toEqual('2019-12-01T00:00:00.000+00:00'); 172 | expect(res.asTimestampUtc().end).toEqual('2019-12-02T00:00:00.000+00:00'); 173 | 174 | res = parse('2019-11-30', new Date('2019-12-26T02:14:05Z'), defaultOpts); 175 | expect(res.asTimestampUtc().start).toEqual('2019-11-30T00:00:00.000+00:00'); 176 | expect(res.asTimestampUtc().end).toEqual('2019-12-01T00:00:00.000+00:00'); 177 | 178 | res = parse('2019-12-01T09:15:32Z', new Date('2019-12-26T02:14:05Z'), defaultOpts); 179 | expect(res.asTimestampUtc().start).toEqual('2019-12-01T09:15:32.000+00:00'); 180 | expect(res.asTimestampUtc().end).toEqual('2019-12-01T09:15:33.000+00:00'); 181 | 182 | res = parse('19:15:32', new Date('2019-12-26T02:14:05Z'), defaultOpts); 183 | expect(res.asTimestampUtc().start).toEqual('2019-12-26T19:15:32.000+00:00'); 184 | expect(res.asTimestampUtc().end).toEqual('2019-12-26T19:15:33.000+00:00'); 185 | 186 | res = parse('15:32', new Date('2019-12-26T02:14:05Z'), defaultOpts); 187 | expect(res.asTimestampUtc().start).toEqual('2019-12-26T15:32:00.000+00:00'); 188 | expect(res.asTimestampUtc().end).toEqual('2019-12-26T15:33:00.000+00:00'); 189 | 190 | res = parse('30:32', new Date('2019-12-26T02:14:05Z'), defaultOpts); 191 | expect(res).toEqual(null); 192 | 193 | res = parse('June 2019', new Date('2019-12-26T02:14:05Z'), defaultOpts); 194 | expect(res.asTimestampUtc().start).toEqual('2019-06-01T00:00:00.000+00:00'); 195 | expect(res.asTimestampUtc().end).toEqual('2019-07-01T00:00:00.000+00:00'); 196 | 197 | res = parse('2019', new Date('2019-12-26T02:14:05Z'), defaultOpts); 198 | expect(res.asTimestampUtc().start).toEqual('2019-01-01T00:00:00.000+00:00'); 199 | expect(res.asTimestampUtc().end).toEqual('2020-01-01T00:00:00.000+00:00'); 200 | 201 | // New tests with tz 202 | 203 | res = parse('2019', new Date('2019-12-26T02:14:05Z'), { ...defaultOpts, timezoneRegion: 'America/Chicago' }); 204 | expect(res.asTimestamp().start).toEqual('2019-01-01T00:00:00.000-06:00'); 205 | expect(res.asTimestamp().end).toEqual('2020-01-01T00:00:00.000-06:00'); 206 | 207 | res = parse('2019-12-01T09:15:32Z till 2019-12-02T09:15:40Z', new Date('2019-12-26T02:14:05Z'), { ...defaultOpts, timezoneRegion: 'America/Chicago' }); 208 | expect(res.asTimestampUtc().start).toEqual('2019-12-01T09:15:32.000+00:00'); 209 | expect(res.asTimestampUtc().end).toEqual('2019-12-02T09:15:40.000+00:00'); 210 | 211 | res = parse('2019-12-01T09:15:32+09:00', new Date('2019-12-26T02:14:05Z'), { ...defaultOpts, timezoneRegion: 'America/Chicago' }); 212 | expect(res.asTimestamp().start).toEqual('2019-11-30T18:15:32.000-06:00'); 213 | expect(res.asTimestamp().end).toEqual('2019-11-30T18:15:33.000-06:00'); 214 | }); 215 | 216 | it('works with dates having just year and month', () => { 217 | let res; 218 | 219 | // YYYY-MM 220 | res = parse('2023-08', new Date('2019-12-26T02:14:05Z'), defaultOpts); 221 | expect(res.asTimestampUtc().start).toEqual('2023-08-01T00:00:00.000+00:00'); 222 | expect(res.asTimestampUtc().end).toEqual('2023-09-01T00:00:00.000+00:00'); 223 | 224 | res = parse('2023-12', new Date('2019-12-26T02:14:05Z'), defaultOpts); 225 | expect(res.asTimestampUtc().start).toEqual('2023-12-01T00:00:00.000+00:00'); 226 | expect(res.asTimestampUtc().end).toEqual('2024-01-01T00:00:00.000+00:00'); 227 | 228 | // YYYY/MM 229 | res = parse('2023/02', new Date('2019-12-26T02:14:05Z'), defaultOpts); 230 | expect(res.asTimestampUtc().start).toEqual('2023-02-01T00:00:00.000+00:00'); 231 | expect(res.asTimestampUtc().end).toEqual('2023-03-01T00:00:00.000+00:00'); 232 | 233 | res = parse('2023/12', new Date('2019-12-26T02:14:05Z'), defaultOpts); 234 | expect(res.asTimestampUtc().start).toEqual('2023-12-01T00:00:00.000+00:00'); 235 | expect(res.asTimestampUtc().end).toEqual('2024-01-01T00:00:00.000+00:00'); 236 | 237 | // With timezone 238 | res = parse('2025-12', new Date('2019-12-26T02:14:05Z'), { ...defaultOpts, timezoneRegion: 'America/Chicago' }); 239 | expect(res.asTimestamp().start).toEqual('2025-12-01T00:00:00.000-06:00'); 240 | expect(res.asTimestamp().end).toEqual('2026-01-01T00:00:00.000-06:00'); 241 | 242 | // DST: Start month 243 | res = parse('2023-03', new Date('2023-03-11T02:14:05Z'), { ...defaultOpts, timezoneRegion: 'America/Chicago' }); 244 | expect(res.asTimestamp().start).toEqual('2023-03-01T00:00:00.000-06:00'); 245 | expect(res.asTimestamp().end).toEqual('2023-04-01T00:00:00.000-05:00'); 246 | 247 | // DST: End month 248 | res = parse('2023-11', new Date('2023-03-11T02:14:05Z'), { ...defaultOpts, timezoneRegion: 'America/Chicago' }); 249 | expect(res.asTimestamp().start).toEqual('2023-11-01T00:00:00.000-05:00'); 250 | expect(res.asTimestamp().end).toEqual('2023-12-01T00:00:00.000-06:00'); 251 | 252 | // Invalid month: fallback to year parser 253 | res = parse('2023/13', new Date('2019-12-26T02:14:05Z'), defaultOpts); 254 | expect(res.asTimestampUtc().start).toEqual('2023-01-01T00:00:00.000+00:00'); 255 | expect(res.asTimestampUtc().end).toEqual('2024-01-01T00:00:00.000+00:00'); 256 | 257 | // Invalid - Look like a code: fallback to year parser 258 | res = parse('2023/10ABC`', new Date('2019-12-26T02:14:05Z'), defaultOpts); 259 | expect(res.asTimestampUtc().start).toEqual('2023-01-01T00:00:00.000+00:00'); 260 | expect(res.asTimestampUtc().end).toEqual('2024-01-01T00:00:00.000+00:00'); 261 | }); 262 | 263 | it('works with today format', () => { 264 | let res; 265 | 266 | res = parse('today', new Date('2019-12-31T02:14:05Z'), defaultOpts); 267 | expect(res.asTimestampUtc().start).toEqual('2019-12-31T00:00:00.000+00:00'); 268 | expect(res.asTimestampUtc().end).toEqual('2020-01-01T00:00:00.000+00:00'); 269 | 270 | res = parse('tomorrow', new Date('2019-12-31T02:14:05Z'), defaultOpts); 271 | expect(res.asTimestampUtc().start).toEqual('2020-01-01T00:00:00.000+00:00'); 272 | expect(res.asTimestampUtc().end).toEqual('2020-01-02T00:00:00.000+00:00'); 273 | 274 | res = parse('yesterday', new Date('2019-12-31T02:14:05Z'), defaultOpts); 275 | expect(res.asTimestampUtc().start).toEqual('2019-12-30T00:00:00.000+00:00'); 276 | expect(res.asTimestampUtc().end).toEqual('2019-12-31T00:00:00.000+00:00'); 277 | 278 | // New tests with tz 279 | res = parse('today', new Date('2019-12-31T18:00:00Z'), { ...defaultOpts, timezoneRegion: 'Asia/Singapore' }); 280 | expect(res.asTimestamp().start).toEqual('2020-01-01T00:00:00.000+08:00'); 281 | expect(res.asTimestamp().end).toEqual('2020-01-02T00:00:00.000+08:00'); 282 | 283 | res = parse('tomorrow', new Date('2021-10-30T02:14:05Z'), { ...defaultOpts, timezoneRegion: 'Europe/Copenhagen' }); 284 | expect(res.asTimestamp().start).toEqual('2021-10-31T00:00:00.000+02:00'); 285 | expect(res.asTimestamp().end).toEqual('2021-11-01T00:00:00.000+01:00'); 286 | 287 | res = parse('yesterday', new Date('2021-03-15T06:00:00Z'), { ...defaultOpts, timezoneRegion: 'America/Chicago' }); 288 | expect(res.asTimestamp().start).toEqual('2021-03-14T00:00:00.000-06:00'); 289 | expect(res.asTimestamp().end).toEqual('2021-03-15T00:00:00.000-05:00'); 290 | }); 291 | 292 | it('can parse constants', () => { 293 | let res; 294 | 295 | res = parse('beginning', new Date('2019-12-31T02:14:05Z'), defaultOpts); 296 | expect(res.asTimestampUtc().start).toEqual('1970-01-01T00:00:00.000+00:00'); 297 | expect(res.asTimestampUtc().end).toEqual('1970-01-01T00:00:01.000+00:00'); 298 | 299 | res = parse('now', new Date('2019-12-31T02:14:05Z'), defaultOpts); 300 | expect(res.asTimestampUtc().start).toEqual('2019-12-31T02:14:05.000+00:00'); 301 | expect(res.asTimestampUtc().end).toEqual('2019-12-31T02:14:06.000+00:00'); 302 | 303 | // New tests with tz 304 | res = parse('now', new Date('2019-12-31T02:14:05Z'), { ...defaultOpts, timezoneRegion: 'Asia/Singapore' }); 305 | expect(res.asTimestamp().start).toEqual('2019-12-31T10:14:05.000+08:00'); 306 | expect(res.asTimestamp().end).toEqual('2019-12-31T10:14:06.000+08:00'); 307 | 308 | res = parse('now', new Date('2019-12-31T02:14:05Z'), { ...defaultOpts, timezoneRegion: 'Europe/Copenhagen' }); 309 | expect(res.asTimestamp().start).toEqual('2019-12-31T03:14:05.000+01:00'); 310 | expect(res.asTimestamp().end).toEqual('2019-12-31T03:14:06.000+01:00'); 311 | }); 312 | 313 | it('works with end-inclusive range', () => { 314 | let res; 315 | 316 | res = parse('beginning - now', new Date('2019-12-31T02:14:05Z'), defaultOpts); 317 | expect(res.asTimestampUtc().start).toEqual('1970-01-01T00:00:00.000+00:00'); 318 | expect(res.asTimestampUtc().end).toEqual('2019-12-31T02:14:06.000+00:00'); 319 | 320 | res = parse('beginning - 3 days ago', new Date('2019-12-31T02:14:05Z'), defaultOpts); 321 | expect(res.asTimestampUtc().start).toEqual('1970-01-01T00:00:00.000+00:00'); 322 | expect(res.asTimestampUtc().end).toEqual('2019-12-29T00:00:00.000+00:00'); 323 | 324 | // auto reorder range 325 | res = parse('3 days ago - beginning', new Date('2019-12-31T02:14:05Z'), defaultOpts); 326 | expect(res.asTimestampUtc().start).toEqual('1970-01-01T00:00:00.000+00:00'); 327 | expect(res.asTimestampUtc().end).toEqual('2019-12-29T00:00:00.000+00:00'); 328 | 329 | res = parse('beginning to 3 days ago', new Date('2019-12-31T02:14:05Z'), defaultOpts); 330 | expect(res.asTimestampUtc().start).toEqual('1970-01-01T00:00:00.000+00:00'); 331 | expect(res.asTimestampUtc().end).toEqual('2019-12-29T00:00:00.000+00:00'); 332 | }); 333 | 334 | it('works with end-exclusive range', () => { 335 | let res; 336 | 337 | res = parse('beginning until now', new Date('2019-12-31T02:14:05Z'), defaultOpts); 338 | expect(res.asTimestampUtc().start).toEqual('1970-01-01T00:00:00.000+00:00'); 339 | expect(res.asTimestampUtc().end).toEqual('2019-12-31T02:14:05.000+00:00'); 340 | 341 | res = parse('beginning till 3 days ago', new Date('2019-12-31T02:14:05Z'), defaultOpts); 342 | expect(res.asTimestampUtc().start).toEqual('1970-01-01T00:00:00.000+00:00'); 343 | expect(res.asTimestampUtc().end).toEqual('2019-12-28T00:00:00.000+00:00'); 344 | 345 | res = parse('beginning until 3 days ago', new Date('2019-12-31T02:14:05Z'), defaultOpts); 346 | expect(res.asTimestampUtc().start).toEqual('1970-01-01T00:00:00.000+00:00'); 347 | expect(res.asTimestampUtc().end).toEqual('2019-12-28T00:00:00.000+00:00'); 348 | 349 | // raises error when start > end 350 | expect(() => parse('tomorrow till 3 days ago', new Date())).toThrowError(/must be before/i); 351 | 352 | // New timezone test 353 | 354 | res = parse('2019-12-28T09:00:00.000+00:00 until 2019-12-28T10:00:00.000+00:00', new Date('2021-03-16T02:14:05Z'), { ...defaultOpts, timezoneRegion: 'Africa/Blantyre' }); 355 | expect(res.asTimestampUtc().start).toEqual('2019-12-28T09:00:00.000+00:00'); 356 | expect(res.asTimestampUtc().end).toEqual('2019-12-28T10:00:00.000+00:00'); 357 | }); 358 | 359 | it('keeps order when date range boundaries overlaps', () => { 360 | let res; 361 | 362 | res = parse('this week - yesterday', new Date('2019-12-31T02:14:05Z'), defaultOpts); 363 | expect(res.asTimestampUtc().start).toEqual('2019-12-30T00:00:00.000+00:00'); 364 | expect(res.asTimestampUtc().end).toEqual('2019-12-31T00:00:00.000+00:00'); 365 | 366 | res = parse('yesterday - this week', new Date('2019-12-31T02:14:05Z'), defaultOpts); 367 | expect(res.asTimestampUtc().start).toEqual('2019-12-30T00:00:00.000+00:00'); 368 | expect(res.asTimestampUtc().end).toEqual('2020-01-06T00:00:00.000+00:00'); 369 | }); 370 | 371 | it('discards invalid range, keeps the valid part only', () => { 372 | let res; 373 | 374 | res = parse('yesterday-today', new Date('2018-06-25T05:00:00+08:00'), { ...defaultOpts, timezoneRegion: 'Africa/Blantyre' }); 375 | expect(res.text).toEqual('yesterday'); 376 | expect(res.asLuxon().start.toFormat('yyyy/MM/dd')).toEqual('2018/06/23'); 377 | expect(res.asLuxon().end.toFormat('yyyy/MM/dd')).toEqual('2018/06/24'); 378 | 379 | res = parse('yesterday till asd', new Date('2018-06-25T05:00:00+08:00'), { ...defaultOpts, timezoneRegion: 'Africa/Blantyre' }); 380 | expect(res.text).toEqual('yesterday'); 381 | expect(res.asLuxon().start.toFormat('yyyy/MM/dd')).toEqual('2018/06/23'); 382 | expect(res.asLuxon().end.toFormat('yyyy/MM/dd')).toEqual('2018/06/24'); 383 | 384 | res = parse('ahihi till yesterday', new Date('2018-06-25T05:00:00+08:00'), { ...defaultOpts, timezoneRegion: 'Africa/Blantyre' }); 385 | expect(res.text).toEqual('yesterday'); 386 | expect(res.asLuxon().start.toFormat('yyyy/MM/dd')).toEqual('2018/06/23'); 387 | expect(res.asLuxon().end.toFormat('yyyy/MM/dd')).toEqual('2018/06/24'); 388 | }); 389 | 390 | it('can parse weekdays', () => { 391 | let res; 392 | 393 | res = parse('thursday this week', new Date('2019-12-26T02:14:05Z'), defaultOpts); 394 | expect(res.asTimestampUtc().start).toEqual('2019-12-26T00:00:00.000+00:00'); 395 | expect(res.asTimestampUtc().end).toEqual('2019-12-27T00:00:00.000+00:00'); 396 | 397 | // uppercase chars 398 | res = parse('thuRsday tHis Week', new Date('2019-12-26T02:14:05Z'), defaultOpts); 399 | expect(res.asTimestampUtc().start).toEqual('2019-12-26T00:00:00.000+00:00'); 400 | expect(res.asTimestampUtc().end).toEqual('2019-12-27T00:00:00.000+00:00'); 401 | 402 | res = parse('thursday current week', new Date('2019-12-26T02:14:05Z'), defaultOpts); 403 | expect(res.asTimestampUtc().start).toEqual('2019-12-26T00:00:00.000+00:00'); 404 | expect(res.asTimestampUtc().end).toEqual('2019-12-27T00:00:00.000+00:00'); 405 | 406 | res = parse('tue last week', new Date('2019-12-26T02:14:05Z'), defaultOpts); 407 | expect(res.asTimestampUtc().start).toEqual('2019-12-17T00:00:00.000+00:00'); 408 | expect(res.asTimestampUtc().end).toEqual('2019-12-18T00:00:00.000+00:00'); 409 | 410 | res = parse('wed next 2 weeks', new Date('2019-12-26T02:14:05Z'), defaultOpts); 411 | expect(res.asTimestampUtc().start).toEqual('2020-01-08T00:00:00.000+00:00'); 412 | expect(res.asTimestampUtc().end).toEqual('2020-01-09T00:00:00.000+00:00'); 413 | 414 | res = parse('friday next weeks', new Date('2019-12-26T02:14:05Z'), defaultOpts); 415 | expect(res.asTimestampUtc().start).toEqual('2020-01-03T00:00:00.000+00:00'); 416 | expect(res.asTimestampUtc().end).toEqual('2020-01-04T00:00:00.000+00:00'); 417 | 418 | // New tests with tz 419 | res = parse('sunday this week', new Date('2021-11-08T05:00:00Z'), { ...defaultOpts, timezoneRegion: 'America/Chicago' }); 420 | expect(res.asTimestamp().start).toEqual('2021-11-07T00:00:00.000-05:00'); 421 | expect(res.asTimestamp().end).toEqual('2021-11-08T00:00:00.000-06:00'); 422 | 423 | res = parse('sunday last week', new Date('2021-03-28T22:00:00Z'), { ...defaultOpts, timezoneRegion: 'Europe/Copenhagen' }); 424 | expect(res.asTimestamp().start).toEqual('2021-03-28T00:00:00.000+01:00'); 425 | expect(res.asTimestamp().end).toEqual('2021-03-29T00:00:00.000+02:00'); 426 | }); 427 | 428 | it('works with weekStartDay', () => { 429 | let res; 430 | 431 | res = parse('last week', new Date('2019-12-26T02:14:05Z'), { ...defaultOpts, weekStartDay: WEEKDAYS.Wednesday }); 432 | expect(res.asTimestampUtc().start).toEqual('2019-12-18T00:00:00.000+00:00'); 433 | expect(res.asTimestampUtc().end).toEqual('2019-12-25T00:00:00.000+00:00'); 434 | 435 | res = parse('last 2 weeks', new Date('2019-12-26T02:14:05Z'), { ...defaultOpts, weekStartDay: WEEKDAYS.Wednesday }); 436 | expect(res.asTimestampUtc().start).toEqual('2019-12-11T00:00:00.000+00:00'); 437 | expect(res.asTimestampUtc().end).toEqual('2019-12-25T00:00:00.000+00:00'); 438 | 439 | res = parse('2 weeks from now', new Date('2019-12-26T02:14:05Z'), { ...defaultOpts, weekStartDay: WEEKDAYS.Wednesday }); 440 | expect(res.asTimestampUtc().start).toEqual('2020-01-09T00:00:00.000+00:00'); 441 | expect(res.asTimestampUtc().end).toEqual('2020-01-16T00:00:00.000+00:00'); 442 | 443 | res = parse('this week', new Date('2019-12-26T02:14:05Z'), { ...defaultOpts, weekStartDay: WEEKDAYS.Wednesday }); 444 | expect(res.asTimestampUtc().start).toEqual('2019-12-25T00:00:00.000+00:00'); 445 | expect(res.asTimestampUtc().end).toEqual('2020-01-01T00:00:00.000+00:00'); 446 | 447 | // This test to make sure the WSD does not effect last month 448 | res = parse('last 2 month', new Date('2019-02-09T02:14:05Z'), { ...defaultOpts, weekStartDay: WEEKDAYS.Wednesday }); 449 | expect(res.asTimestampUtc().start).toEqual('2018-12-01T00:00:00.000+00:00'); 450 | expect(res.asTimestampUtc().end).toEqual('2019-02-01T00:00:00.000+00:00'); 451 | 452 | // This test to make sure the WSD does not effect the last year 453 | res = parse('last year', new Date('2020-02-29T02:14:05Z'), { ...defaultOpts, weekStartDay: WEEKDAYS.Wednesday }); 454 | expect(res.asTimestampUtc().start).toEqual('2019-01-01T00:00:00.000+00:00'); 455 | expect(res.asTimestampUtc().end).toEqual('2020-01-01T00:00:00.000+00:00'); 456 | 457 | res = parse('tue last week', new Date('2019-12-26T02:14:05Z'), { ...defaultOpts, weekStartDay: WEEKDAYS.Wednesday }); 458 | expect(res.asTimestampUtc().start).toEqual('2019-12-24T00:00:00.000+00:00'); 459 | expect(res.asTimestampUtc().end).toEqual('2019-12-25T00:00:00.000+00:00'); 460 | 461 | res = parse('tue next 2 weeks', new Date('2019-12-26T02:14:05Z'), { ...defaultOpts, weekStartDay: WEEKDAYS.Wednesday }); 462 | expect(res.asTimestampUtc().start).toEqual('2020-01-14T00:00:00.000+00:00'); 463 | expect(res.asTimestampUtc().end).toEqual('2020-01-15T00:00:00.000+00:00'); 464 | 465 | res = parse('mon next week', new Date('2019-12-26T02:14:05Z'), { ...defaultOpts, weekStartDay: WEEKDAYS.Sunday }); 466 | expect(res.asTimestampUtc().start).toEqual('2019-12-30T00:00:00.000+00:00'); 467 | expect(res.asTimestampUtc().end).toEqual('2019-12-31T00:00:00.000+00:00'); 468 | 469 | res = parse('sat next week', new Date('2019-12-26T02:14:05Z'), { ...defaultOpts, weekStartDay: WEEKDAYS.Sunday }); 470 | expect(res.asTimestampUtc().start).toEqual('2020-01-04T00:00:00.000+00:00'); 471 | expect(res.asTimestampUtc().end).toEqual('2020-01-05T00:00:00.000+00:00'); 472 | 473 | res = parse('sat next week', new Date('2019-12-26T02:14:05Z'), { ...defaultOpts, weekStartDay: WEEKDAYS.Saturday }); 474 | expect(res.asTimestampUtc().start).toEqual('2019-12-28T00:00:00.000+00:00'); 475 | expect(res.asTimestampUtc().end).toEqual('2019-12-29T00:00:00.000+00:00'); 476 | 477 | res = parse('thu next week', new Date('2019-12-26T02:14:05Z'), { ...defaultOpts, weekStartDay: WEEKDAYS.Thursday }); 478 | expect(res.asTimestampUtc().start).toEqual('2020-01-02T00:00:00.000+00:00'); 479 | expect(res.asTimestampUtc().end).toEqual('2020-01-03T00:00:00.000+00:00'); 480 | 481 | res = parse('thu last week', new Date('2019-12-26T02:14:05Z'), { ...defaultOpts, weekStartDay: WEEKDAYS.Friday }); 482 | expect(res.asTimestampUtc().start).toEqual('2019-12-19T00:00:00.000+00:00'); 483 | expect(res.asTimestampUtc().end).toEqual('2019-12-20T00:00:00.000+00:00'); 484 | 485 | res = parse('last week begin', new Date('2021-05-10T22:14:05Z'), { ...defaultOpts, weekStartDay: WEEKDAYS.Tuesday }); 486 | expect(res.asTimestampUtc().start).toEqual('2021-04-27T00:00:00.000+00:00'); 487 | expect(res.asTimestampUtc().end).toEqual('2021-04-28T00:00:00.000+00:00'); 488 | 489 | res = parse('last week end', new Date('2021-05-10T22:14:05Z'), { ...defaultOpts, weekStartDay: WEEKDAYS.Tuesday }); 490 | expect(res.asTimestampUtc().start).toEqual('2021-05-03T00:00:00.000+00:00'); 491 | expect(res.asTimestampUtc().end).toEqual('2021-05-04T00:00:00.000+00:00'); 492 | 493 | res = parse('last week end', new Date('2021-05-12T03:14:05Z'), { ...defaultOpts, weekStartDay: WEEKDAYS.Tuesday, timezoneRegion: 'America/Chicago' }); 494 | expect(res.asTimestamp().start).toEqual('2021-05-10T00:00:00.000-05:00'); 495 | expect(res.asTimestamp().end).toEqual('2021-05-11T00:00:00.000-05:00'); 496 | }); 497 | 498 | it('has good behavior with default parsers', () => { 499 | let res = null; 500 | 501 | // ambiguous 502 | res = parse('within 3 days', new Date('2019-12-26T04:35:19+08:00'), { ...defaultOpts, timezoneRegion: 'Asia/Seoul' }); 503 | expect(res).toEqual(null); 504 | }); 505 | 506 | it('rejects invalid reference date', () => { 507 | expect(() => parse('today', 'ahehe')).toThrowError(/invalid ref/i); 508 | }); 509 | 510 | it('rejects invalid timezone region', () => { 511 | expect(() => parse('today', new Date(), { ...defaultOpts, timezoneRegion: 'asd' })).toThrowError(/invalid timezone region/i); 512 | }); 513 | 514 | it('detect ambiguous input and raise informative error', () => { 515 | expect(() => parse('this mon', new Date(), defaultOpts)).toThrowError(/ambiguous.*mon this week/i); 516 | expect(() => parse('last monday', new Date(), defaultOpts)).toThrowError(/ambiguous.*monday last week/i); 517 | expect(() => parse('next Friday', new Date(), defaultOpts)).toThrowError(/ambiguous.*Friday next week/); 518 | expect(() => parse('thursday', new Date(), defaultOpts)).toThrowError(/ambiguous.*thursday last\/this\/next week/); 519 | }); 520 | 521 | it('raises error when weekStartDay is invalid', () => { 522 | expect(() => parse('this mon', new Date(), { ...defaultOpts, weekStartDay: 'ahihi' })).toThrowError(/invalid weekStartDay/i); 523 | }); 524 | 525 | it('should throw error when order is invalid', () => { 526 | expect(() => { 527 | parse('2021-10-01 till 2021-09-10', new Date(), defaultOpts); 528 | }).toThrow(/start date must be before end date/i); 529 | }); 530 | 531 | it('rejects invalid reference date', () => { 532 | expect(() => parse('today', 'ahehe', defaultOpts)).toThrowError(/invalid ref/i); 533 | }); 534 | 535 | it('default inputs should work', () => { 536 | const res = parseV3('last 2 days', new Date('2019-12-26T02:14:05Z')); 537 | expect(res.start).toEqual('2019-12-24T00:00:00.000+00:00'); 538 | expect(res.end).toEqual('2019-12-26T00:00:00.000+00:00'); 539 | }); 540 | 541 | it('invalid text', () => { 542 | parse('meomeo', new Date(), { ...defaultOpts, timezoneRegion: 'Asia/Singapore' }); 543 | parse('last', new Date(), { ...defaultOpts, output: 'timestamp', timezoneRegion: 'Asia/Singapore' }); 544 | }); 545 | 546 | it('reject when parsing invalid ISO date', () => { 547 | expect(() => parse('2019-02-29T09:15:32+09:00', new Date('2019-12-26T02:14:05Z'), { ...defaultOpts, timezoneRegion: 'America/Chicago' })).toThrowError(/unit out of range/i); 548 | }); 549 | }); 550 | 551 | describe('output types', () => { 552 | const defaultOpts = { parserVersion: PARSER_VERSION_3 }; 553 | 554 | it('support common output types', () => { 555 | let res; 556 | 557 | res = parse('last 2 days', new Date('2019-12-26T02:14:05Z'), { ...defaultOpts, output: 'date', timezoneRegion: 'America/Chicago' }); 558 | expect(res.start).toEqual('2019-12-23'); 559 | expect(res.end).toEqual('2019-12-25'); 560 | 561 | res = parse('last 2 days', new Date('2019-12-26T02:14:05Z'), { ...defaultOpts, output: 'timestamp', timezoneRegion: 'America/Chicago' }); 562 | expect(res.start).toEqual('2019-12-23T00:00:00.000-06:00'); 563 | expect(res.end).toEqual('2019-12-25T00:00:00.000-06:00'); 564 | 565 | res = parse('last 2 days', new Date('2019-12-26T02:14:05Z'), { ...defaultOpts, output: 'timestamp_utc', timezoneRegion: 'America/Chicago' }); 566 | expect(res.start).toEqual('2019-12-23T06:00:00.000+00:00'); 567 | expect(res.end).toEqual('2019-12-25T06:00:00.000+00:00'); 568 | 569 | res = parse('last 2 days', new Date('2019-12-26T02:14:05Z'), { ...defaultOpts, output: 'luxon', timezoneRegion: 'America/Chicago' }); 570 | expect(res.start.toISO()).toEqual('2019-12-23T00:00:00.000-06:00'); 571 | 572 | res = parse('last 2 days', new Date('2019-12-26T02:14:05Z'), { ...defaultOpts, timezoneRegion: 'America/Chicago' }); 573 | expect(res.start).toEqual('2019-12-23T00:00:00.000-06:00'); 574 | }); 575 | }); 576 | 577 | describe('default Chrono parsers should work well despite system timezone', () => { 578 | const defaultOpts = { parserVersion: PARSER_VERSION_3, output: 'raw', timezoneRegion: 'Asia/Seoul' }; 579 | 580 | it('ENISOFormatParser', () => { 581 | let res; 582 | 583 | res = parse('2019-12-01T09:15:32Z', new Date('2019-12-26T02:14:05Z'), { ...defaultOpts }); 584 | expect(res.asTimestamp().start).toEqual('2019-12-01T18:15:32.000+09:00'); 585 | expect(res.asTimestamp().end).toEqual('2019-12-01T18:15:33.000+09:00'); 586 | 587 | res = parse('2019-12-01T09:15:32+09:00', new Date('2019-12-26T02:14:05Z'), { ...defaultOpts, timezoneRegion: 'America/Chicago' }); 588 | expect(res.asTimestamp().start).toEqual('2019-11-30T18:15:32.000-06:00'); 589 | expect(res.asTimestamp().end).toEqual('2019-11-30T18:15:33.000-06:00'); 590 | 591 | res = parse('2019-12-01', new Date('2019-12-26T02:14:05Z'), defaultOpts); 592 | expect(res.asTimestamp().start).toEqual('2019-12-01T00:00:00.000+09:00'); 593 | expect(res.asTimestamp().end).toEqual('2019-12-02T00:00:00.000+09:00'); 594 | 595 | res = parse('2019-12-27T00:14:05', new Date('2019-12-26T02:14:05Z'), defaultOpts); 596 | expect(res.asTimestamp().start).toEqual('2019-12-27T09:14:05.000+09:00'); 597 | expect(res.asTimestamp().end).toEqual('2019-12-27T09:14:06.000+09:00'); 598 | }); 599 | 600 | it('ENMonthNameLittleEndianParser', () => { 601 | const res = parse('1 August 2021', new Date('2021-11-16 00:00:00+00:00'), { ...defaultOpts, timezoneRegion: 'Asia/Seoul', output: 'timestamp' }); 602 | expect(res.start).toEqual('2021-08-01T00:00:00.000+09:00'); 603 | expect(res.end).toEqual('2021-08-02T00:00:00.000+09:00'); 604 | }); 605 | 606 | it('ENMonthNameMiddleEndianParser', () => { 607 | const res = parse('August 1, 2021', new Date('2021-11-16 00:00:00+00:00'), { ...defaultOpts, timezoneRegion: 'Asia/Seoul', output: 'timestamp' }); 608 | expect(res.start).toEqual('2021-08-01T00:00:00.000+09:00'); 609 | expect(res.end).toEqual('2021-08-02T00:00:00.000+09:00'); 610 | }); 611 | 612 | it('ENMonthNameParser', () => { 613 | const res = parse('aug2021', new Date('2021-11-16 00:00:00+00:00'), { ...defaultOpts, timezoneRegion: 'Asia/Seoul', output: 'timestamp' }); 614 | expect(res.start).toEqual('2021-08-01T00:00:00.000+09:00'); 615 | expect(res.end).toEqual('2021-09-01T00:00:00.000+09:00'); 616 | }); 617 | 618 | it('ENSlashDateFormatParser', () => { 619 | const res = parse('8/1/2021', new Date('2021-11-16 00:00:00+00:00'), { ...defaultOpts, timezoneRegion: 'Asia/Seoul', output: 'timestamp' }); 620 | expect(res.start).toEqual('2021-08-01T00:00:00.000+09:00'); 621 | expect(res.end).toEqual('2021-08-02T00:00:00.000+09:00'); 622 | }); 623 | 624 | it('ENSlashDateFormatStartWithYear', () => { 625 | const res = parse('2021/08/01', new Date('2021-11-16 00:00:00+00:00'), { ...defaultOpts, timezoneRegion: 'Asia/Seoul', output: 'timestamp' }); 626 | expect(res.start).toEqual('2021-08-01T00:00:00.000+09:00'); 627 | expect(res.end).toEqual('2021-08-02T00:00:00.000+09:00'); 628 | }); 629 | 630 | it('ENSlashMonthFormat', () => { 631 | const res = parse('8/2021', new Date('2021-11-16 00:00:00+00:00'), { ...defaultOpts, timezoneRegion: 'Asia/Seoul', output: 'timestamp' }); 632 | expect(res.start).toEqual('2021-08-01T00:00:00.000+09:00'); 633 | expect(res.end).toEqual('2021-09-01T00:00:00.000+09:00'); 634 | }); 635 | 636 | it('ENTimeExpressionParser', () => { 637 | const res = parse('3 o\'clock - 3 minutes ago', new Date('2019-12-26T04:35:19+08:00'), { ...defaultOpts, timezoneRegion: 'Asia/Seoul' }); 638 | expect(res.text).toEqual("3 o'clock - 3 minutes ago"); 639 | expect(res.asTimestampUtc().start).toEqual('2019-12-25T18:00:00.000+00:00'); 640 | expect(res.asTimestampUtc().end).toEqual('2019-12-25T20:33:00.000+00:00'); 641 | 642 | expect(res.asTimestamp().start).toEqual('2019-12-26T03:00:00.000+09:00'); 643 | expect(res.asTimestamp().end).toEqual('2019-12-26T05:33:00.000+09:00'); 644 | }); 645 | }); 646 | 647 | describe('rawResult', () => { 648 | const defaultOpts = { parserVersion: PARSER_VERSION_3, output: 'date', timezoneRegion: 'Asia/Seoul' }; 649 | 650 | it('output date return rawResult', () => { 651 | const res = parse('2019-12-01 - 2019-12-02', new Date('2019-12-26T02:14:05Z'), { ...defaultOpts }); 652 | expect(res.start).toEqual('2019-12-01'); 653 | expect(res.end).toEqual('2019-12-03'); 654 | expect(res.rawResult.start).toEqual('2019-12-01T00:00:00.000+09:00'); 655 | expect(res.rawResult.end).toEqual('2019-12-03T00:00:00.000+09:00'); 656 | }); 657 | 658 | it('output date return rawResult case include time', () => { 659 | const res = parse('2019-12-02 12:00:00 - 2019-12-03 23:59:59', new Date('2019-12-26T02:14:05Z'), { ...defaultOpts }); 660 | expect(res.start).toEqual('2019-12-02'); 661 | expect(res.end).toEqual('2019-12-04'); 662 | expect(res.rawResult.start).toEqual('2019-12-02T12:00:00.000+09:00'); 663 | expect(res.rawResult.end).toEqual('2019-12-04T00:00:00.000+09:00'); 664 | }); 665 | 666 | it('output timestamp return rawResult', () => { 667 | const res = parse('2019-12-01 - 2019-12-02', new Date('2019-12-26T02:14:05Z'), { parserVersion: PARSER_VERSION_3, output: 'timestamp', timezoneRegion: 'Asia/Seoul' }); 668 | expect(res.start).toEqual('2019-12-01T00:00:00.000+09:00'); 669 | expect(res.end).toEqual('2019-12-03T00:00:00.000+09:00'); 670 | expect(res.rawResult.start).toEqual('2019-12-01T00:00:00.000+09:00'); 671 | expect(res.rawResult.end).toEqual('2019-12-03T00:00:00.000+09:00'); 672 | }); 673 | 674 | it('output timestamp_utc return rawResult', () => { 675 | const res = parse('2019-12-01 - 2019-12-02', new Date('2019-12-26T02:14:05Z'), { parserVersion: PARSER_VERSION_3, output: 'timestamp_utc', timezoneRegion: 'Asia/Seoul' }); 676 | expect(res.start).toEqual('2019-11-30T15:00:00.000+00:00'); 677 | expect(res.end).toEqual('2019-12-02T15:00:00.000+00:00'); 678 | expect(res.rawResult.start).toEqual('2019-12-01T00:00:00.000+09:00'); 679 | expect(res.rawResult.end).toEqual('2019-12-03T00:00:00.000+09:00'); 680 | }); 681 | 682 | it('output luxon return rawResult', () => { 683 | const res = parse('2019-12-01 - 2019-12-02', new Date('2019-12-26T02:14:05Z'), { parserVersion: PARSER_VERSION_3, output: 'luxon', timezoneRegion: 'Asia/Seoul' }); 684 | expect(res.rawResult.start).toEqual('2019-12-01T00:00:00.000+09:00'); 685 | expect(res.rawResult.end).toEqual('2019-12-03T00:00:00.000+09:00'); 686 | }); 687 | }); 688 | -------------------------------------------------------------------------------- /packages/date-parser/src/index.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | parse, WEEKDAYS, OUTPUT_TYPES, Errors, 3 | } from './index'; 4 | import { InputError } from './errors'; 5 | import { parse as parseV1 } from './dateParserV1'; 6 | 7 | describe('common tests', () => { 8 | it('exports necessary constants', () => { 9 | expect(!!OUTPUT_TYPES).toBe(true); 10 | 11 | expect(!!Errors).toBe(true); 12 | expect(!!Errors.InputError).toBe(true); 13 | }); 14 | }); 15 | 16 | describe('dateParser', () => { 17 | it('throw error when exceed character limit', () => { 18 | expect(() => { 19 | parse(`last week${' '.repeat(1000)}`, new Date('2019-12-26T02:14:05Z')); 20 | }).toThrow(new InputError('Date value exceeds limit of 200 characters')); 21 | }); 22 | 23 | it('works with lastX format', () => { 24 | let res; 25 | 26 | res = parse('last week', new Date('2019-12-26T02:14:05Z')); 27 | expect(res).toMatchObject({ 28 | start: { 29 | knownValues: { 30 | year: 2019, month: 12, day: 16, hour: 0, minute: 0, second: 0, 31 | }, 32 | impliedValues: { millisecond: 0 }, 33 | }, 34 | end: { 35 | knownValues: { 36 | year: 2019, month: 12, day: 23, hour: 0, minute: 0, second: 0, 37 | }, 38 | impliedValues: { millisecond: 0 }, 39 | }, 40 | }); 41 | expect(res.start.date().toISOString()).toEqual('2019-12-16T00:00:00.000Z'); 42 | expect(res.end.date().toISOString()).toEqual('2019-12-23T00:00:00.000Z'); 43 | 44 | res = parse('this week', new Date('2019-12-26T02:14:05Z')); 45 | expect(res).toMatchObject({ 46 | start: { 47 | knownValues: { 48 | year: 2019, month: 12, day: 23, hour: 0, minute: 0, second: 0, 49 | }, 50 | impliedValues: { millisecond: 0 }, 51 | }, 52 | end: { 53 | knownValues: { 54 | year: 2019, month: 12, day: 30, hour: 0, minute: 0, second: 0, 55 | }, 56 | impliedValues: { millisecond: 0 }, 57 | }, 58 | }); 59 | expect(res.start.date().toISOString()).toEqual('2019-12-23T00:00:00.000Z'); 60 | expect(res.end.date().toISOString()).toEqual('2019-12-30T00:00:00.000Z'); 61 | 62 | res = parse('last month', new Date('2019-12-26T02:14:05Z')); 63 | expect(res).toMatchObject({ 64 | start: { 65 | knownValues: { 66 | year: 2019, month: 11, day: 1, hour: 0, minute: 0, second: 0, 67 | }, 68 | impliedValues: { millisecond: 0 }, 69 | }, 70 | end: { 71 | knownValues: { 72 | year: 2019, month: 12, day: 1, hour: 0, minute: 0, second: 0, 73 | }, 74 | impliedValues: { millisecond: 0 }, 75 | }, 76 | }); 77 | expect(res.start.date().toISOString()).toEqual('2019-11-01T00:00:00.000Z'); 78 | expect(res.end.date().toISOString()).toEqual('2019-12-01T00:00:00.000Z'); 79 | 80 | res = parse('last quarter', new Date('2019-12-26T02:14:05Z')); 81 | expect(res).toMatchObject({ 82 | start: { 83 | knownValues: { 84 | year: 2019, month: 7, day: 1, hour: 0, minute: 0, second: 0, 85 | }, 86 | impliedValues: { millisecond: 0 }, 87 | }, 88 | end: { 89 | knownValues: { 90 | year: 2019, month: 10, day: 1, hour: 0, minute: 0, second: 0, 91 | }, 92 | impliedValues: { millisecond: 0 }, 93 | }, 94 | }); 95 | expect(res.start.date().toISOString()).toEqual('2019-07-01T00:00:00.000Z'); 96 | expect(res.end.date().toISOString()).toEqual('2019-10-01T00:00:00.000Z'); 97 | 98 | res = parse('last year', new Date('2020-02-29T02:14:05Z')); 99 | expect(res).toMatchObject({ 100 | start: { 101 | knownValues: { 102 | year: 2019, month: 1, day: 1, hour: 0, minute: 0, second: 0, 103 | }, 104 | impliedValues: { millisecond: 0 }, 105 | }, 106 | end: { 107 | knownValues: { 108 | year: 2020, month: 1, day: 1, hour: 0, minute: 0, second: 0, 109 | }, 110 | impliedValues: { millisecond: 0 }, 111 | }, 112 | }); 113 | expect(res.start.date().toISOString()).toEqual('2019-01-01T00:00:00.000Z'); 114 | expect(res.end.date().toISOString()).toEqual('2020-01-01T00:00:00.000Z'); 115 | 116 | res = parse('this month begin', new Date('2019-12-26T02:14:05Z')); 117 | expect(res).toMatchObject({ 118 | start: { 119 | knownValues: { 120 | year: 2019, month: 12, day: 1, hour: 0, minute: 0, second: 0, 121 | }, 122 | impliedValues: { millisecond: 0 }, 123 | }, 124 | end: { 125 | knownValues: { 126 | year: 2019, month: 12, day: 2, hour: 0, minute: 0, second: 0, 127 | }, 128 | impliedValues: { millisecond: 0 }, 129 | }, 130 | }); 131 | expect(res.start.date().toISOString()).toEqual('2019-12-01T00:00:00.000Z'); 132 | expect(res.end.date().toISOString()).toEqual('2019-12-02T00:00:00.000Z'); 133 | 134 | res = parse('current month begin', new Date('2019-12-26T02:14:05Z')); 135 | expect(res.start.date().toISOString()).toEqual('2019-12-01T00:00:00.000Z'); 136 | expect(res.end.date().toISOString()).toEqual('2019-12-02T00:00:00.000Z'); 137 | 138 | res = parse('last month end', new Date('2019-12-26T02:14:05Z')); 139 | expect(res).toMatchObject({ 140 | start: { 141 | knownValues: { 142 | year: 2019, month: 11, day: 30, hour: 0, minute: 0, second: 0, 143 | }, 144 | impliedValues: { millisecond: 0 }, 145 | }, 146 | end: { 147 | knownValues: { 148 | year: 2019, month: 12, day: 1, hour: 0, minute: 0, second: 0, 149 | }, 150 | impliedValues: { millisecond: 0 }, 151 | }, 152 | }); 153 | expect(res.start.date().toISOString()).toEqual('2019-11-30T00:00:00.000Z'); 154 | expect(res.end.date().toISOString()).toEqual('2019-12-01T00:00:00.000Z'); 155 | 156 | res = parse('last 2 month', new Date('2019-02-09T02:14:05Z')); 157 | expect(res).toMatchObject({ 158 | start: { 159 | knownValues: { 160 | year: 2018, month: 12, day: 1, hour: 0, minute: 0, second: 0, 161 | }, 162 | impliedValues: { millisecond: 0 }, 163 | }, 164 | end: { 165 | knownValues: { 166 | year: 2019, month: 2, day: 1, hour: 0, minute: 0, second: 0, 167 | }, 168 | impliedValues: { millisecond: 0 }, 169 | }, 170 | }); 171 | expect(res.start.date().toISOString()).toEqual('2018-12-01T00:00:00.000Z'); 172 | expect(res.end.date().toISOString()).toEqual('2019-02-01T00:00:00.000Z'); 173 | 174 | // plural date unit 175 | res = parse('last 2 months', new Date('2019-02-09T02:14:05Z')); 176 | expect(res).toMatchObject({ 177 | start: { 178 | knownValues: { 179 | year: 2018, month: 12, day: 1, hour: 0, minute: 0, second: 0, 180 | }, 181 | impliedValues: { millisecond: 0 }, 182 | }, 183 | end: { 184 | knownValues: { 185 | year: 2019, month: 2, day: 1, hour: 0, minute: 0, second: 0, 186 | }, 187 | impliedValues: { millisecond: 0 }, 188 | }, 189 | }); 190 | expect(res.start.date().toISOString()).toEqual('2018-12-01T00:00:00.000Z'); 191 | expect(res.end.date().toISOString()).toEqual('2019-02-01T00:00:00.000Z'); 192 | 193 | // uppercase chars 194 | res = parse('LAST 2 mOnth', new Date('2019-02-09T02:14:05Z')); 195 | expect(res.start.date().toISOString()).toEqual('2018-12-01T00:00:00.000Z'); 196 | expect(res.end.date().toISOString()).toEqual('2019-02-01T00:00:00.000Z'); 197 | 198 | res = parse('last 2 months begin', new Date('2019-02-09T02:14:05Z')); 199 | expect(res).toMatchObject({ 200 | start: { 201 | knownValues: { 202 | year: 2018, month: 12, day: 1, hour: 0, minute: 0, second: 0, 203 | }, 204 | impliedValues: { millisecond: 0 }, 205 | }, 206 | end: { 207 | knownValues: { 208 | year: 2018, month: 12, day: 2, hour: 0, minute: 0, second: 0, 209 | }, 210 | impliedValues: { millisecond: 0 }, 211 | }, 212 | }); 213 | expect(res.start.date().toISOString()).toEqual('2018-12-01T00:00:00.000Z'); 214 | expect(res.end.date().toISOString()).toEqual('2018-12-02T00:00:00.000Z'); 215 | 216 | res = parse('last 2 days', new Date('2019-12-26T02:14:05Z')); 217 | expect(res).toMatchObject({ 218 | start: { 219 | knownValues: { 220 | year: 2019, month: 12, day: 24, hour: 0, minute: 0, second: 0, 221 | }, 222 | impliedValues: { millisecond: 0 }, 223 | }, 224 | end: { 225 | knownValues: { 226 | year: 2019, month: 12, day: 26, hour: 0, minute: 0, second: 0, 227 | }, 228 | impliedValues: { millisecond: 0 }, 229 | }, 230 | }); 231 | expect(res.start.date().toISOString()).toEqual('2019-12-24T00:00:00.000Z'); 232 | expect(res.end.date().toISOString()).toEqual('2019-12-26T00:00:00.000Z'); 233 | 234 | res = parse('last 2 hours begin', new Date('2019-12-26T01:14:05Z')); 235 | expect(res).toMatchObject({ 236 | start: { 237 | knownValues: { 238 | year: 2019, month: 12, day: 25, hour: 23, minute: 0, second: 0, 239 | }, 240 | impliedValues: { millisecond: 0 }, 241 | }, 242 | end: { 243 | knownValues: { 244 | year: 2019, month: 12, day: 25, hour: 23, minute: 0, second: 1, 245 | }, 246 | impliedValues: { millisecond: 0 }, 247 | }, 248 | }); 249 | expect(res.start.date().toISOString()).toEqual('2019-12-25T23:00:00.000Z'); 250 | expect(res.end.date().toISOString()).toEqual('2019-12-25T23:00:01.000Z'); 251 | 252 | res = parse('next 2 minutes end', new Date('2019-12-26T01:14:05Z')); 253 | expect(res).toMatchObject({ 254 | start: { 255 | knownValues: { 256 | year: 2019, month: 12, day: 26, hour: 1, minute: 16, second: 59, 257 | }, 258 | impliedValues: { millisecond: 0 }, 259 | }, 260 | end: { 261 | knownValues: { 262 | year: 2019, month: 12, day: 26, hour: 1, minute: 17, second: 0, 263 | }, 264 | impliedValues: { millisecond: 0 }, 265 | }, 266 | }); 267 | expect(res.start.date().toISOString()).toEqual('2019-12-26T01:16:59.000Z'); 268 | expect(res.end.date().toISOString()).toEqual('2019-12-26T01:17:00.000Z'); 269 | }); 270 | 271 | it('works with xAgo format', () => { 272 | let res; 273 | 274 | res = parse('2 days ago', new Date('2019-12-26T02:14:05Z')); 275 | expect(res).toMatchObject({ 276 | start: { 277 | knownValues: { 278 | year: 2019, month: 12, day: 24, hour: 0, minute: 0, second: 0, 279 | }, 280 | impliedValues: { millisecond: 0 }, 281 | }, 282 | end: { 283 | knownValues: { 284 | year: 2019, month: 12, day: 25, hour: 0, minute: 0, second: 0, 285 | }, 286 | impliedValues: { millisecond: 0 }, 287 | }, 288 | }); 289 | expect(res.start.date().toISOString()).toEqual('2019-12-24T00:00:00.000Z'); 290 | expect(res.end.date().toISOString()).toEqual('2019-12-25T00:00:00.000Z'); 291 | 292 | res = parse('exact 2 days ago', new Date('2019-12-26T02:14:05Z')); 293 | expect(res).toMatchObject({ 294 | start: { 295 | knownValues: { 296 | year: 2019, month: 12, day: 24, hour: 2, minute: 14, second: 5, 297 | }, 298 | impliedValues: { millisecond: 0 }, 299 | }, 300 | end: { 301 | knownValues: { 302 | year: 2019, month: 12, day: 24, hour: 2, minute: 14, second: 6, 303 | }, 304 | impliedValues: { millisecond: 0 }, 305 | }, 306 | }); 307 | expect(res.start.date().toISOString()).toEqual('2019-12-24T02:14:05.000Z'); 308 | expect(res.end.date().toISOString()).toEqual('2019-12-24T02:14:06.000Z'); 309 | 310 | res = parse('3 weeks from now', new Date('2019-12-26T02:14:05Z')); 311 | expect(res).toMatchObject({ 312 | start: { 313 | knownValues: { 314 | year: 2020, month: 1, day: 16, hour: 0, minute: 0, second: 0, 315 | }, 316 | impliedValues: { millisecond: 0 }, 317 | }, 318 | end: { 319 | knownValues: { 320 | year: 2020, month: 1, day: 23, hour: 0, minute: 0, second: 0, 321 | }, 322 | impliedValues: { millisecond: 0 }, 323 | }, 324 | }); 325 | expect(res.start.date().toISOString()).toEqual('2020-01-16T00:00:00.000Z'); 326 | expect(res.end.date().toISOString()).toEqual('2020-01-23T00:00:00.000Z'); 327 | 328 | res = parse('exactly 3 weeks from now', new Date('2019-12-26T02:14:05Z')); 329 | expect(res).toMatchObject({ 330 | start: { 331 | knownValues: { 332 | year: 2020, month: 1, day: 16, hour: 2, minute: 14, second: 5, 333 | }, 334 | impliedValues: { millisecond: 0 }, 335 | }, 336 | end: { 337 | knownValues: { 338 | year: 2020, month: 1, day: 16, hour: 2, minute: 14, second: 6, 339 | }, 340 | impliedValues: { millisecond: 0 }, 341 | }, 342 | }); 343 | expect(res.start.date().toISOString()).toEqual('2020-01-16T02:14:05.000Z'); 344 | expect(res.end.date().toISOString()).toEqual('2020-01-16T02:14:06.000Z'); 345 | 346 | res = parse('1 year ago', new Date('2020-02-29T02:14:05Z')); 347 | expect(res).toMatchObject({ 348 | start: { 349 | knownValues: { 350 | year: 2019, month: 1, day: 1, hour: 0, minute: 0, second: 0, 351 | }, 352 | impliedValues: { millisecond: 0 }, 353 | }, 354 | end: { 355 | knownValues: { 356 | year: 2020, month: 1, day: 1, hour: 0, minute: 0, second: 0, 357 | }, 358 | impliedValues: { millisecond: 0 }, 359 | }, 360 | }); 361 | expect(res.start.date().toISOString()).toEqual('2019-01-01T00:00:00.000Z'); 362 | expect(res.end.date().toISOString()).toEqual('2020-01-01T00:00:00.000Z'); 363 | 364 | res = parse('exactly 1 year ago', new Date('2020-02-29T02:14:05Z')); 365 | expect(res).toMatchObject({ 366 | start: { 367 | knownValues: { 368 | year: 2019, month: 2, day: 28, hour: 2, minute: 14, second: 5, 369 | }, 370 | impliedValues: { millisecond: 0 }, 371 | }, 372 | end: { 373 | knownValues: { 374 | year: 2019, month: 2, day: 28, hour: 2, minute: 14, second: 6, 375 | }, 376 | impliedValues: { millisecond: 0 }, 377 | }, 378 | }); 379 | expect(res.start.date().toISOString()).toEqual('2019-02-28T02:14:05.000Z'); 380 | expect(res.end.date().toISOString()).toEqual('2019-02-28T02:14:06.000Z'); 381 | 382 | res = parse('1 year ago for 5 days', new Date('2020-02-29T02:14:05Z')); 383 | expect(res).toMatchObject({ 384 | start: { 385 | knownValues: { 386 | year: 2019, month: 1, day: 1, hour: 0, minute: 0, second: 0, 387 | }, 388 | impliedValues: { millisecond: 0 }, 389 | }, 390 | end: { 391 | knownValues: { 392 | year: 2019, month: 1, day: 6, hour: 0, minute: 0, second: 0, 393 | }, 394 | impliedValues: { millisecond: 0 }, 395 | }, 396 | }); 397 | expect(res.start.date().toISOString()).toEqual('2019-01-01T00:00:00.000Z'); 398 | expect(res.end.date().toISOString()).toEqual('2019-01-06T00:00:00.000Z'); 399 | 400 | res = parse('exactly 1 year ago for 5 days', new Date('2020-02-29T02:14:05Z')); 401 | expect(res).toMatchObject({ 402 | start: { 403 | knownValues: { 404 | year: 2019, month: 2, day: 28, hour: 2, minute: 14, second: 5, 405 | }, 406 | impliedValues: { millisecond: 0 }, 407 | }, 408 | end: { 409 | knownValues: { 410 | year: 2019, month: 3, day: 5, hour: 2, minute: 14, second: 5, 411 | }, 412 | impliedValues: { millisecond: 0 }, 413 | }, 414 | }); 415 | expect(res.start.date().toISOString()).toEqual('2019-02-28T02:14:05.000Z'); 416 | expect(res.end.date().toISOString()).toEqual('2019-03-05T02:14:05.000Z'); 417 | }); 418 | 419 | it('works with absolute, both full and partial, dates', () => { 420 | let res; 421 | 422 | res = parse('2019/12/01', new Date('2019-12-26T02:14:05Z')); 423 | expect(res).toMatchObject({ 424 | start: { 425 | knownValues: { 426 | year: 2019, month: 12, day: 1, 427 | }, 428 | impliedValues: { 429 | hour: 0, minute: 0, second: 0, millisecond: 0, 430 | }, 431 | }, 432 | end: { 433 | knownValues: {}, 434 | impliedValues: { 435 | year: 2019, month: 12, day: 2, hour: 0, minute: 0, second: 0, millisecond: 0, 436 | }, 437 | }, 438 | }); 439 | expect(res.start.date().toISOString()).toEqual('2019-12-01T00:00:00.000Z'); 440 | expect(res.end.date().toISOString()).toEqual('2019-12-02T00:00:00.000Z'); 441 | 442 | res = parse('2019-12-01', new Date('2019-12-26T02:14:05Z')); 443 | expect(res).toMatchObject({ 444 | start: { 445 | knownValues: { 446 | year: 2019, month: 12, day: 1, 447 | }, 448 | impliedValues: { 449 | hour: 0, minute: 0, second: 0, millisecond: 0, 450 | }, 451 | }, 452 | end: { 453 | knownValues: {}, 454 | impliedValues: { 455 | year: 2019, month: 12, day: 2, hour: 0, minute: 0, second: 0, millisecond: 0, 456 | }, 457 | }, 458 | }); 459 | expect(res.start.date().toISOString()).toEqual('2019-12-01T00:00:00.000Z'); 460 | expect(res.end.date().toISOString()).toEqual('2019-12-02T00:00:00.000Z'); 461 | 462 | res = parse('2019-11-30', new Date('2019-12-26T02:14:05Z')); 463 | expect(res).toMatchObject({ 464 | start: { 465 | knownValues: { 466 | year: 2019, month: 11, day: 30, 467 | }, 468 | impliedValues: { 469 | hour: 0, minute: 0, second: 0, millisecond: 0, 470 | }, 471 | }, 472 | end: { 473 | knownValues: {}, 474 | impliedValues: { 475 | year: 2019, month: 11, day: 31, hour: 0, minute: 0, second: 0, millisecond: 0, 476 | }, 477 | }, 478 | }); 479 | expect(res.start.date().toISOString()).toEqual('2019-11-30T00:00:00.000Z'); 480 | expect(res.end.date().toISOString()).toEqual('2019-12-01T00:00:00.000Z'); 481 | 482 | res = parse('2019-12-01T09:15:32Z', new Date('2019-12-26T02:14:05Z')); 483 | expect(res).toMatchObject({ 484 | start: { 485 | knownValues: { 486 | year: 2019, month: 12, day: 1, hour: 9, minute: 15, second: 32, 487 | }, 488 | impliedValues: { millisecond: 0 }, 489 | }, 490 | end: { 491 | knownValues: {}, 492 | impliedValues: { 493 | year: 2019, month: 12, day: 1, hour: 9, minute: 15, second: 33, millisecond: 0, 494 | }, 495 | }, 496 | }); 497 | expect(res.start.date().toISOString()).toEqual('2019-12-01T09:15:32.000Z'); 498 | expect(res.end.date().toISOString()).toEqual('2019-12-01T09:15:33.000Z'); 499 | 500 | res = parse('19:15:32', new Date('2019-12-26T02:14:05Z')); 501 | expect(res).toMatchObject({ 502 | start: { 503 | knownValues: { 504 | hour: 19, minute: 15, second: 32, 505 | }, 506 | impliedValues: { 507 | year: 2019, month: 12, day: 26, millisecond: 0, 508 | }, 509 | }, 510 | end: { 511 | knownValues: {}, 512 | impliedValues: { 513 | year: 2019, month: 12, day: 26, hour: 19, minute: 15, second: 33, millisecond: 0, 514 | }, 515 | }, 516 | }); 517 | expect(res.start.date().toISOString()).toEqual('2019-12-26T19:15:32.000Z'); 518 | expect(res.end.date().toISOString()).toEqual('2019-12-26T19:15:33.000Z'); 519 | 520 | res = parse('15:32', new Date('2019-12-26T02:14:05Z')); 521 | expect(res).toMatchObject({ 522 | start: { 523 | knownValues: { 524 | hour: 15, minute: 32, 525 | }, 526 | impliedValues: { 527 | year: 2019, month: 12, day: 26, second: 0, millisecond: 0, 528 | }, 529 | }, 530 | end: { 531 | knownValues: {}, 532 | impliedValues: { 533 | year: 2019, month: 12, day: 26, hour: 15, minute: 33, second: 0, millisecond: 0, 534 | }, 535 | }, 536 | }); 537 | expect(res.start.date().toISOString()).toEqual('2019-12-26T15:32:00.000Z'); 538 | expect(res.end.date().toISOString()).toEqual('2019-12-26T15:33:00.000Z'); 539 | 540 | res = parse('30:32', new Date('2019-12-26T02:14:05Z')); 541 | expect(res).toEqual(null); 542 | 543 | res = parse('June 2019', new Date('2019-12-26T02:14:05Z')); 544 | expect(res).toMatchObject({ 545 | start: { 546 | knownValues: { 547 | year: 2019, month: 6, 548 | }, 549 | impliedValues: { 550 | day: 1, hour: 0, minute: 0, second: 0, millisecond: 0, 551 | }, 552 | }, 553 | end: { 554 | knownValues: {}, 555 | impliedValues: { 556 | year: 2019, month: 7, day: 1, hour: 0, minute: 0, second: 0, millisecond: 0, 557 | }, 558 | }, 559 | }); 560 | expect(res.start.date().toISOString()).toEqual('2019-06-01T00:00:00.000Z'); 561 | expect(res.end.date().toISOString()).toEqual('2019-07-01T00:00:00.000Z'); 562 | 563 | res = parse('2019', new Date('2019-12-26T02:14:05Z')); 564 | expect(res).toMatchObject({ 565 | start: { 566 | knownValues: { 567 | year: 2019, month: 1, day: 1, 568 | }, 569 | impliedValues: { 570 | hour: 0, minute: 0, second: 0, millisecond: 0, 571 | }, 572 | }, 573 | end: { 574 | knownValues: { 575 | year: 2020, month: 1, day: 1, 576 | }, 577 | impliedValues: { 578 | hour: 0, minute: 0, second: 0, millisecond: 0, 579 | }, 580 | }, 581 | }); 582 | expect(res.start.date().toISOString()).toEqual('2019-01-01T00:00:00.000Z'); 583 | expect(res.end.date().toISOString()).toEqual('2020-01-01T00:00:00.000Z'); 584 | }); 585 | 586 | it('works with today format', () => { 587 | let res; 588 | 589 | res = parse('today', new Date('2019-12-31T02:14:05Z')); 590 | expect(res).toMatchObject({ 591 | start: { 592 | knownValues: { 593 | year: 2019, month: 12, day: 31, hour: 0, minute: 0, second: 0, 594 | }, 595 | impliedValues: { millisecond: 0 }, 596 | }, 597 | end: { 598 | knownValues: { 599 | year: 2020, month: 1, day: 1, hour: 0, minute: 0, second: 0, 600 | }, 601 | impliedValues: { millisecond: 0 }, 602 | }, 603 | }); 604 | expect(res.start.date().toISOString()).toEqual('2019-12-31T00:00:00.000Z'); 605 | expect(res.end.date().toISOString()).toEqual('2020-01-01T00:00:00.000Z'); 606 | 607 | res = parse('tomorrow', new Date('2019-12-31T02:14:05Z')); 608 | expect(res).toMatchObject({ 609 | start: { 610 | knownValues: { 611 | year: 2020, month: 1, day: 1, hour: 0, minute: 0, second: 0, 612 | }, 613 | impliedValues: { millisecond: 0 }, 614 | }, 615 | end: { 616 | knownValues: { 617 | year: 2020, month: 1, day: 2, hour: 0, minute: 0, second: 0, 618 | }, 619 | impliedValues: { millisecond: 0 }, 620 | }, 621 | }); 622 | expect(res.start.date().toISOString()).toEqual('2020-01-01T00:00:00.000Z'); 623 | expect(res.end.date().toISOString()).toEqual('2020-01-02T00:00:00.000Z'); 624 | 625 | res = parse('yesterday', new Date('2019-12-31T02:14:05Z')); 626 | expect(res).toMatchObject({ 627 | start: { 628 | knownValues: { 629 | year: 2019, month: 12, day: 30, hour: 0, minute: 0, second: 0, 630 | }, 631 | impliedValues: { millisecond: 0 }, 632 | }, 633 | end: { 634 | knownValues: { 635 | year: 2019, month: 12, day: 31, hour: 0, minute: 0, second: 0, 636 | }, 637 | impliedValues: { millisecond: 0 }, 638 | }, 639 | }); 640 | expect(res.start.date().toISOString()).toEqual('2019-12-30T00:00:00.000Z'); 641 | expect(res.end.date().toISOString()).toEqual('2019-12-31T00:00:00.000Z'); 642 | }); 643 | 644 | it('can parse constants', () => { 645 | let res; 646 | 647 | res = parse('beginning', new Date('2019-12-31T02:14:05Z')); 648 | expect(res).toMatchObject({ 649 | start: { 650 | knownValues: { 651 | year: 1970, month: 1, day: 1, hour: 0, minute: 0, second: 0, 652 | }, 653 | impliedValues: { millisecond: 0 }, 654 | }, 655 | end: { 656 | knownValues: {}, 657 | impliedValues: { millisecond: 0 }, 658 | }, 659 | }); 660 | expect(res.start.date().toISOString()).toEqual('1970-01-01T00:00:00.000Z'); 661 | expect(res.end.date().toISOString()).toEqual('1970-01-01T00:00:01.000Z'); 662 | 663 | res = parse('now', new Date('2019-12-31T02:14:05Z')); 664 | expect(res).toMatchObject({ 665 | start: { 666 | knownValues: { 667 | year: 2019, month: 12, day: 31, hour: 2, minute: 14, second: 5, 668 | }, 669 | impliedValues: { millisecond: 0 }, 670 | }, 671 | end: { 672 | knownValues: {}, 673 | impliedValues: { 674 | year: 2019, month: 12, day: 31, hour: 2, minute: 14, second: 6, millisecond: 0, 675 | }, 676 | }, 677 | }); 678 | expect(res.start.date().toISOString()).toEqual('2019-12-31T02:14:05.000Z'); 679 | expect(res.end.date().toISOString()).toEqual('2019-12-31T02:14:06.000Z'); 680 | }); 681 | 682 | it('works with end-inclusive range', () => { 683 | let res; 684 | 685 | res = parse('beginning - now', new Date('2019-12-31T02:14:05Z')); 686 | expect(res).toMatchObject({ 687 | start: { 688 | knownValues: { 689 | year: 1970, month: 1, day: 1, hour: 0, minute: 0, second: 0, 690 | }, 691 | impliedValues: { millisecond: 0 }, 692 | }, 693 | end: { 694 | knownValues: { 695 | }, 696 | impliedValues: { 697 | year: 2019, month: 12, day: 31, hour: 2, minute: 14, second: 6, millisecond: 0, 698 | }, 699 | }, 700 | }); 701 | expect(res.start.date().toISOString()).toEqual('1970-01-01T00:00:00.000Z'); 702 | expect(res.end.date().toISOString()).toEqual('2019-12-31T02:14:06.000Z'); 703 | 704 | res = parse('beginning - 3 days ago', new Date('2019-12-31T02:14:05Z')); 705 | expect(res).toMatchObject({ 706 | start: { 707 | knownValues: { 708 | year: 1970, month: 1, day: 1, hour: 0, minute: 0, second: 0, 709 | }, 710 | impliedValues: { millisecond: 0 }, 711 | }, 712 | end: { 713 | knownValues: { 714 | year: 2019, month: 12, day: 29, hour: 0, minute: 0, second: 0, 715 | }, 716 | impliedValues: { millisecond: 0 }, 717 | }, 718 | }); 719 | expect(res.start.date().toISOString()).toEqual('1970-01-01T00:00:00.000Z'); 720 | expect(res.end.date().toISOString()).toEqual('2019-12-29T00:00:00.000Z'); 721 | 722 | // auto reorder range 723 | res = parse('3 days ago - beginning', new Date('2019-12-31T02:14:05Z')); 724 | expect(res.start.date().toISOString()).toEqual('1970-01-01T00:00:00.000Z'); 725 | expect(res.end.date().toISOString()).toEqual('2019-12-29T00:00:00.000Z'); 726 | 727 | res = parse('beginning to 3 days ago', new Date('2019-12-31T02:14:05Z'), { output: 'raw' }); 728 | expect(res[0]).toMatchObject({ 729 | text: 'beginning', 730 | start: { 731 | knownValues: { 732 | year: 1970, month: 1, day: 1, hour: 0, minute: 0, second: 0, 733 | }, 734 | impliedValues: { millisecond: 0 }, 735 | }, 736 | end: { 737 | knownValues: {}, 738 | impliedValues: { 739 | year: 1970, month: 1, day: 1, hour: 0, minute: 0, second: 1, millisecond: 0, 740 | }, 741 | }, 742 | }); 743 | expect(res[0].start.date().toISOString()).toEqual('1970-01-01T00:00:00.000Z'); 744 | expect(res[0].end.date().toISOString()).toEqual('1970-01-01T00:00:01.000Z'); 745 | expect(res[1]).toMatchObject({ 746 | text: '3 days ago', 747 | start: { 748 | knownValues: { 749 | year: 2019, month: 12, day: 28, hour: 0, minute: 0, second: 0, 750 | }, 751 | impliedValues: { millisecond: 0 }, 752 | }, 753 | end: { 754 | knownValues: { 755 | year: 2019, month: 12, day: 29, hour: 0, minute: 0, second: 0, 756 | }, 757 | impliedValues: { millisecond: 0 }, 758 | }, 759 | }); 760 | expect(res[1].start.date().toISOString()).toEqual('2019-12-28T00:00:00.000Z'); 761 | expect(res[1].end.date().toISOString()).toEqual('2019-12-29T00:00:00.000Z'); 762 | }); 763 | 764 | it('works with end-exclusive range', () => { 765 | let res; 766 | 767 | res = parse('beginning until now', new Date('2019-12-31T02:14:05Z')); 768 | expect(res).toMatchObject({ 769 | start: { 770 | knownValues: { 771 | year: 1970, month: 1, day: 1, hour: 0, minute: 0, second: 0, 772 | }, 773 | impliedValues: { millisecond: 0 }, 774 | }, 775 | end: { 776 | knownValues: { 777 | year: 2019, month: 12, day: 31, hour: 2, minute: 14, second: 5, 778 | }, 779 | impliedValues: { millisecond: 0 }, 780 | }, 781 | }); 782 | expect(res.start.date().toISOString()).toEqual('1970-01-01T00:00:00.000Z'); 783 | expect(res.end.date().toISOString()).toEqual('2019-12-31T02:14:05.000Z'); 784 | 785 | res = parse('beginning till 3 days ago', new Date('2019-12-31T02:14:05Z')); 786 | expect(res).toMatchObject({ 787 | start: { 788 | knownValues: { 789 | year: 1970, month: 1, day: 1, hour: 0, minute: 0, second: 0, 790 | }, 791 | impliedValues: { millisecond: 0 }, 792 | }, 793 | end: { 794 | knownValues: { 795 | year: 2019, month: 12, day: 28, hour: 0, minute: 0, second: 0, 796 | }, 797 | impliedValues: { millisecond: 0 }, 798 | }, 799 | }); 800 | expect(res.start.date().toISOString()).toEqual('1970-01-01T00:00:00.000Z'); 801 | expect(res.end.date().toISOString()).toEqual('2019-12-28T00:00:00.000Z'); 802 | 803 | res = parse('beginning until 3 days ago', new Date('2019-12-31T02:14:05Z'), { output: 'raw' }); 804 | expect(res[0]).toMatchObject({ 805 | text: 'beginning', 806 | start: { 807 | knownValues: { 808 | year: 1970, month: 1, day: 1, hour: 0, minute: 0, second: 0, 809 | }, 810 | impliedValues: { millisecond: 0 }, 811 | }, 812 | end: { 813 | knownValues: {}, 814 | impliedValues: { 815 | year: 1970, month: 1, day: 1, hour: 0, minute: 0, second: 1, millisecond: 0, 816 | }, 817 | }, 818 | }); 819 | expect(res[0].start.date().toISOString()).toEqual('1970-01-01T00:00:00.000Z'); 820 | expect(res[0].end.date().toISOString()).toEqual('1970-01-01T00:00:01.000Z'); 821 | expect(res[1]).toMatchObject({ 822 | text: '3 days ago', 823 | start: { 824 | knownValues: { 825 | year: 2019, month: 12, day: 28, hour: 0, minute: 0, second: 0, 826 | }, 827 | impliedValues: { millisecond: 0 }, 828 | }, 829 | end: { 830 | knownValues: { 831 | year: 2019, month: 12, day: 29, hour: 0, minute: 0, second: 0, 832 | }, 833 | impliedValues: { millisecond: 0 }, 834 | }, 835 | }); 836 | expect(res[1].start.date().toISOString()).toEqual('2019-12-28T00:00:00.000Z'); 837 | expect(res[1].end.date().toISOString()).toEqual('2019-12-29T00:00:00.000Z'); 838 | 839 | res = parse('3 days ago till 15:36', new Date('2019-12-31T02:14:05Z')); 840 | expect(res).toMatchObject({ 841 | start: { 842 | knownValues: { 843 | year: 2019, month: 12, day: 28, hour: 0, minute: 0, second: 0, 844 | }, 845 | impliedValues: { millisecond: 0 }, 846 | }, 847 | end: { 848 | knownValues: { 849 | hour: 15, minute: 36, 850 | }, 851 | impliedValues: { 852 | year: 2019, month: 12, day: 31, second: 0, millisecond: 0, 853 | }, 854 | }, 855 | }); 856 | expect(res.start.date().toISOString()).toEqual('2019-12-28T00:00:00.000Z'); 857 | expect(res.end.date().toISOString()).toEqual('2019-12-31T15:36:00.000Z'); 858 | 859 | // raises error when start > end 860 | expect(() => parse('tomorrow till 3 days ago', new Date())).toThrowError(/must be before/i); 861 | 862 | // works normally even with timezoneOffset 863 | res = parse('2019-12-28T09:00:00.000Z until 2019-12-28T10:00:00.000Z', new Date('2021-03-16T02:14:05Z'), { timezoneOffset: 120 }); 864 | expect(res).toMatchObject({ 865 | start: { 866 | knownValues: { 867 | year: 2019, month: 12, day: 28, hour: 9, minute: 0, second: 0, millisecond: 0, timezoneOffset: 0, 868 | }, 869 | impliedValues: {}, 870 | }, 871 | end: { 872 | knownValues: { 873 | year: 2019, month: 12, day: 28, hour: 10, minute: 0, second: 0, millisecond: 0, timezoneOffset: 0, 874 | }, 875 | impliedValues: {}, 876 | }, 877 | }); 878 | expect(res.start.date().toISOString()).toEqual('2019-12-28T09:00:00.000Z'); 879 | expect(res.end.date().toISOString()).toEqual('2019-12-28T10:00:00.000Z'); 880 | }); 881 | 882 | it('keeps order when date range boundaries overlaps', () => { 883 | let res; 884 | 885 | res = parse('this week - yesterday', new Date('2019-12-31T02:14:05Z')); 886 | expect(res.start.date().toISOString()).toEqual('2019-12-30T00:00:00.000Z'); 887 | expect(res.end.date().toISOString()).toEqual('2019-12-31T00:00:00.000Z'); 888 | 889 | res = parse('yesterday - this week', new Date('2019-12-31T02:14:05Z')); 890 | expect(res.start.date().toISOString()).toEqual('2019-12-30T00:00:00.000Z'); 891 | expect(res.end.date().toISOString()).toEqual('2020-01-06T00:00:00.000Z'); 892 | }); 893 | 894 | it('discards invalid range, keeps the valid part only', () => { 895 | let res; 896 | 897 | res = parse('yesterday-today', new Date('2018-06-25T05:00:00+08:00'), { timezoneOffset: 60 }); 898 | expect(res.text).toEqual('yesterday'); 899 | expect(res.start.moment().format('YYYY/MM/DD')).toEqual('2018/06/23'); 900 | expect(res.end.moment().format('YYYY/MM/DD')).toEqual('2018/06/24'); 901 | 902 | res = parse('yesterday till asd', new Date('2018-06-25T05:00:00+08:00'), { timezoneOffset: 60 }); 903 | expect(res.text).toEqual('yesterday'); 904 | expect(res.start.moment().format('YYYY/MM/DD')).toEqual('2018/06/23'); 905 | expect(res.end.moment().format('YYYY/MM/DD')).toEqual('2018/06/24'); 906 | 907 | res = parse('ahihi till yesterday', new Date('2018-06-25T05:00:00+08:00'), { timezoneOffset: 60 }); 908 | expect(res.text).toEqual('yesterday'); 909 | expect(res.start.moment().format('YYYY/MM/DD')).toEqual('2018/06/23'); 910 | expect(res.end.moment().format('YYYY/MM/DD')).toEqual('2018/06/24'); 911 | }); 912 | 913 | it('can parse weekdays', () => { 914 | let res; 915 | 916 | res = parse('thursday this week', new Date('2019-12-26T02:14:05Z')); 917 | expect(res).toMatchObject({ 918 | start: { 919 | knownValues: { 920 | year: 2019, month: 12, day: 26, hour: 0, minute: 0, second: 0, 921 | }, 922 | impliedValues: { millisecond: 0 }, 923 | }, 924 | end: { 925 | knownValues: { 926 | year: 2019, month: 12, day: 27, hour: 0, minute: 0, second: 0, 927 | }, 928 | impliedValues: { millisecond: 0 }, 929 | }, 930 | }); 931 | expect(res.start.date().toISOString()).toEqual('2019-12-26T00:00:00.000Z'); 932 | expect(res.end.date().toISOString()).toEqual('2019-12-27T00:00:00.000Z'); 933 | 934 | // uppercase chars 935 | res = parse('thuRsday tHis Week', new Date('2019-12-26T02:14:05Z')); 936 | expect(res.start.date().toISOString()).toEqual('2019-12-26T00:00:00.000Z'); 937 | expect(res.end.date().toISOString()).toEqual('2019-12-27T00:00:00.000Z'); 938 | 939 | res = parse('thursday current week', new Date('2019-12-26T02:14:05Z')); 940 | expect(res.start.date().toISOString()).toEqual('2019-12-26T00:00:00.000Z'); 941 | expect(res.end.date().toISOString()).toEqual('2019-12-27T00:00:00.000Z'); 942 | 943 | res = parse('tue last week', new Date('2019-12-26T02:14:05Z')); 944 | expect(res).toMatchObject({ 945 | start: { 946 | knownValues: { 947 | year: 2019, month: 12, day: 17, hour: 0, minute: 0, second: 0, 948 | }, 949 | impliedValues: { millisecond: 0 }, 950 | }, 951 | end: { 952 | knownValues: { 953 | year: 2019, month: 12, day: 18, hour: 0, minute: 0, second: 0, 954 | }, 955 | impliedValues: { millisecond: 0 }, 956 | }, 957 | }); 958 | expect(res.start.date().toISOString()).toEqual('2019-12-17T00:00:00.000Z'); 959 | expect(res.end.date().toISOString()).toEqual('2019-12-18T00:00:00.000Z'); 960 | 961 | res = parse('wed next 2 weeks', new Date('2019-12-26T02:14:05Z')); 962 | expect(res).toMatchObject({ 963 | start: { 964 | knownValues: { 965 | year: 2020, month: 1, day: 8, hour: 0, minute: 0, second: 0, 966 | }, 967 | impliedValues: { millisecond: 0 }, 968 | }, 969 | end: { 970 | knownValues: { 971 | year: 2020, month: 1, day: 9, hour: 0, minute: 0, second: 0, 972 | }, 973 | impliedValues: { millisecond: 0 }, 974 | }, 975 | }); 976 | expect(res.start.date().toISOString()).toEqual('2020-01-08T00:00:00.000Z'); 977 | expect(res.end.date().toISOString()).toEqual('2020-01-09T00:00:00.000Z'); 978 | 979 | res = parse('friday next weeks', new Date('2019-12-26T02:14:05Z')); 980 | expect(res).toMatchObject({ 981 | start: { 982 | knownValues: { 983 | year: 2020, month: 1, day: 3, hour: 0, minute: 0, second: 0, 984 | }, 985 | impliedValues: { millisecond: 0 }, 986 | }, 987 | end: { 988 | knownValues: { 989 | year: 2020, month: 1, day: 4, hour: 0, minute: 0, second: 0, 990 | }, 991 | impliedValues: { millisecond: 0 }, 992 | }, 993 | }); 994 | expect(res.start.date().toISOString()).toEqual('2020-01-03T00:00:00.000Z'); 995 | expect(res.end.date().toISOString()).toEqual('2020-01-04T00:00:00.000Z'); 996 | }); 997 | 998 | it('works with timezones', () => { 999 | let res; 1000 | 1001 | res = parse('last week begin', new Date('2018-06-25T05:00:00+08:00'), { timezoneOffset: 60 }); 1002 | expect(res.start.date().toISOString()).toEqual('2018-06-10T23:00:00.000Z'); 1003 | expect(res.end.date().toISOString()).toEqual('2018-06-11T23:00:00.000Z'); 1004 | res = parse('last week begin', new Date('2018-06-25T05:00:00+08:00'), { timezoneOffset: 180 }); 1005 | expect(res.start.date().toISOString()).toEqual('2018-06-17T21:00:00.000Z'); 1006 | expect(res.end.date().toISOString()).toEqual('2018-06-18T21:00:00.000Z'); 1007 | 1008 | res = parse('this week end', new Date('2018-01-01T05:00:00+08:00'), { timezoneOffset: 60 }); 1009 | expect(res.start.date().toISOString()).toEqual('2017-12-30T23:00:00.000Z'); 1010 | expect(res.end.date().toISOString()).toEqual('2017-12-31T23:00:00.000Z'); 1011 | res = parse('this week end', new Date('2018-01-01T05:00:00+08:00'), { timezoneOffset: 180 }); 1012 | expect(res.start.date().toISOString()).toEqual('2018-01-06T21:00:00.000Z'); 1013 | expect(res.end.date().toISOString()).toEqual('2018-01-07T21:00:00.000Z'); 1014 | 1015 | res = parse('this month begin', new Date('2018-01-01T05:00:00+08:00'), { timezoneOffset: 60 }); 1016 | expect(res.start.date().toISOString()).toEqual('2017-11-30T23:00:00.000Z'); 1017 | expect(res.end.date().toISOString()).toEqual('2017-12-01T23:00:00.000Z'); 1018 | res = parse('this month begin', new Date('2018-01-01T05:00:00+08:00'), { timezoneOffset: 180 }); 1019 | expect(res.start.date().toISOString()).toEqual('2017-12-31T21:00:00.000Z'); 1020 | expect(res.end.date().toISOString()).toEqual('2018-01-01T21:00:00.000Z'); 1021 | 1022 | res = parse('last month end', new Date('2018-01-01T05:00:00+08:00'), { timezoneOffset: 60 }); 1023 | expect(res.start.date().toISOString()).toEqual('2017-11-29T23:00:00.000Z'); 1024 | expect(res.end.date().toISOString()).toEqual('2017-11-30T23:00:00.000Z'); 1025 | res = parse('last month end', new Date('2018-01-01T05:00:00+08:00'), { timezoneOffset: 180 }); 1026 | expect(res.start.date().toISOString()).toEqual('2017-12-30T21:00:00.000Z'); 1027 | expect(res.end.date().toISOString()).toEqual('2017-12-31T21:00:00.000Z'); 1028 | 1029 | res = parse('next month end', '2018-01-01T05:00:00+08:00', { timezoneOffset: 60 }); 1030 | expect(res.start.date().toISOString()).toEqual('2018-01-30T23:00:00.000Z'); 1031 | expect(res.end.date().toISOString()).toEqual('2018-01-31T23:00:00.000Z'); 1032 | res = parse('next month end', '2018-01-01T05:00:00+08:00', { timezoneOffset: 180 }); 1033 | expect(res.start.date().toISOString()).toEqual('2018-02-27T21:00:00.000Z'); 1034 | expect(res.end.date().toISOString()).toEqual('2018-02-28T21:00:00.000Z'); 1035 | 1036 | res = parse('yesterday', new Date('2019-04-11T23:00:00+00:00'), { timezoneOffset: 540 }); 1037 | expect(res.start.date().toISOString()).toEqual('2019-04-10T15:00:00.000Z'); 1038 | expect(res.end.date().toISOString()).toEqual('2019-04-11T15:00:00.000Z'); 1039 | expect(res.start.moment().utcOffset()).toEqual(540); 1040 | expect(res.end.moment().utcOffset()).toEqual(540); 1041 | expect(res.start.moment().format('YYYY-MM-DD')).toEqual('2019-04-11'); 1042 | expect(res.end.moment().format('YYYY-MM-DD')).toEqual('2019-04-12'); 1043 | expect(res.ref.toISOString()).toEqual('2019-04-11T23:00:00.000Z'); 1044 | 1045 | res = parse('June 2019', new Date('2019-12-25T23:00:00+00:00'), { timezoneOffset: 540 }); 1046 | expect(res.start.date().toISOString()).toEqual('2019-05-31T15:00:00.000Z'); 1047 | expect(res.end.date().toISOString()).toEqual('2019-06-30T15:00:00.000Z'); 1048 | 1049 | res = parse('exactly 3 days ago', new Date('2019-12-26T04:35:19+08:00'), { timezoneOffset: 540 }); 1050 | expect(res.start.date().toISOString()).toEqual('2019-12-22T20:35:19.000Z'); 1051 | expect(res.end.date().toISOString()).toEqual('2019-12-22T20:35:20.000Z'); 1052 | }); 1053 | 1054 | it('has good behavior with default parsers', () => { 1055 | let res; 1056 | 1057 | res = parse('3 o\'clock - 3 minutes ago', new Date('2019-12-26T04:35:19+08:00'), { timezoneOffset: 540 }); 1058 | expect(res.text).toEqual("3 o'clock - 3 minutes ago"); 1059 | expect(res.start.date().toISOString()).toEqual('2019-12-25T18:00:00.000Z'); 1060 | expect(res.end.date().toISOString()).toEqual('2019-12-25T20:33:00.000Z'); 1061 | 1062 | // ambiguous 1063 | res = parse('within 3 days', new Date('2019-12-26T04:35:19+08:00'), { timezoneOffset: 540 }); 1064 | expect(res).toEqual(null); 1065 | }); 1066 | 1067 | it('rejects invalid reference date', () => { 1068 | expect(() => parse('today', 'ahehe')).toThrowError(/invalid ref/i); 1069 | }); 1070 | 1071 | it('rejects invalid timezoneOffset', () => { 1072 | expect(() => parse('today', new Date(), { timezoneOffset: 'asd' })).toThrowError(/invalid timezoneOffset/i); 1073 | }); 1074 | 1075 | it('cant output in timestamp format', () => { 1076 | let res; 1077 | 1078 | res = parse('yesterday', new Date('2019-04-11T22:00:00+00:00'), { timezoneOffset: 420, output: 'timestamp' }); 1079 | expect(res.start).toEqual('2019-04-10T17:00:00.000Z'); 1080 | expect(res.end).toEqual('2019-04-11T17:00:00.000Z'); 1081 | res = parse('yesterday', new Date('2019-04-11T22:00:00+00:00'), { timezoneOffset: 540, output: 'timestamp' }); 1082 | expect(res.start).toEqual('2019-04-10T15:00:00.000Z'); 1083 | expect(res.end).toEqual('2019-04-11T15:00:00.000Z'); 1084 | res = parse('yesterday', new Date('2019-04-11T22:00:00+00:00'), { timezoneOffset: 60, output: 'timestamp' }); 1085 | expect(res.start).toEqual('2019-04-09T23:00:00.000Z'); 1086 | expect(res.end).toEqual('2019-04-10T23:00:00.000Z'); 1087 | 1088 | res = parse('yesterday', new Date('2019-04-11T22:00:00+00:00'), { timezoneOffset: 420, output: 'date' }); 1089 | expect(res.start).toEqual('2019-04-11'); 1090 | expect(res.end).toEqual('2019-04-12'); 1091 | res = parse('yesterday', new Date('2019-04-11T22:00:00+00:00'), { timezoneOffset: 540, output: 'date' }); 1092 | expect(res.start).toEqual('2019-04-11'); 1093 | expect(res.end).toEqual('2019-04-12'); 1094 | res = parse('yesterday', new Date('2019-04-11T22:00:00+00:00'), { timezoneOffset: 60, output: 'date' }); 1095 | expect(res.start).toEqual('2019-04-10'); 1096 | expect(res.end).toEqual('2019-04-11'); 1097 | res = parse('yesterday', new Date('2019-04-11T22:00:00+00:00'), { output: 'date' }); 1098 | expect(res.start).toEqual('2019-04-10'); 1099 | expect(res.end).toEqual('2019-04-11'); 1100 | }); 1101 | 1102 | it('detect ambiguous input and raise informative error', () => { 1103 | expect(() => parse('this mon', new Date())).toThrowError(/ambiguous.*mon this week/i); 1104 | expect(() => parse('last monday', new Date())).toThrowError(/ambiguous.*monday last week/i); 1105 | expect(() => parse('next Friday', new Date())).toThrowError(/ambiguous.*Friday next week/); 1106 | expect(() => parse('thursday', new Date())).toThrowError(/ambiguous.*thursday last\/this\/next week/); 1107 | }); 1108 | 1109 | it('works with weekStartDay', () => { 1110 | let res; 1111 | 1112 | res = parse('last week', new Date('2019-12-26T02:14:05Z'), { weekStartDay: WEEKDAYS.Wednesday }); 1113 | expect(res).toMatchObject({ 1114 | start: { 1115 | knownValues: { 1116 | year: 2019, month: 12, day: 18, hour: 0, minute: 0, second: 0, 1117 | }, 1118 | impliedValues: { millisecond: 0 }, 1119 | }, 1120 | end: { 1121 | knownValues: { 1122 | year: 2019, month: 12, day: 25, hour: 0, minute: 0, second: 0, 1123 | }, 1124 | impliedValues: { millisecond: 0 }, 1125 | }, 1126 | }); 1127 | expect(res.start.date().toISOString()).toEqual('2019-12-18T00:00:00.000Z'); 1128 | expect(res.end.date().toISOString()).toEqual('2019-12-25T00:00:00.000Z'); 1129 | 1130 | res = parse('last 2 weeks', new Date('2019-12-26T02:14:05Z'), { weekStartDay: WEEKDAYS.Wednesday }); 1131 | expect(res).toMatchObject({ 1132 | start: { 1133 | knownValues: { 1134 | year: 2019, month: 12, day: 11, hour: 0, minute: 0, second: 0, 1135 | }, 1136 | impliedValues: { millisecond: 0 }, 1137 | }, 1138 | end: { 1139 | knownValues: { 1140 | year: 2019, month: 12, day: 25, hour: 0, minute: 0, second: 0, 1141 | }, 1142 | impliedValues: { millisecond: 0 }, 1143 | }, 1144 | }); 1145 | expect(res.start.date().toISOString()).toEqual('2019-12-11T00:00:00.000Z'); 1146 | expect(res.end.date().toISOString()).toEqual('2019-12-25T00:00:00.000Z'); 1147 | 1148 | res = parse('2 weeks from now', new Date('2019-12-26T02:14:05Z'), { weekStartDay: WEEKDAYS.Wednesday }); 1149 | expect(res).toMatchObject({ 1150 | start: { 1151 | knownValues: { 1152 | year: 2020, month: 1, day: 9, hour: 0, minute: 0, second: 0, 1153 | }, 1154 | impliedValues: { millisecond: 0 }, 1155 | }, 1156 | end: { 1157 | knownValues: { 1158 | year: 2020, month: 1, day: 16, hour: 0, minute: 0, second: 0, 1159 | }, 1160 | impliedValues: { millisecond: 0 }, 1161 | }, 1162 | }); 1163 | expect(res.start.date().toISOString()).toEqual('2020-01-09T00:00:00.000Z'); 1164 | expect(res.end.date().toISOString()).toEqual('2020-01-16T00:00:00.000Z'); 1165 | 1166 | res = parse('this week', new Date('2019-12-26T02:14:05Z'), { weekStartDay: WEEKDAYS.Wednesday }); 1167 | expect(res).toMatchObject({ 1168 | start: { 1169 | knownValues: { 1170 | year: 2019, month: 12, day: 25, hour: 0, minute: 0, second: 0, 1171 | }, 1172 | impliedValues: { millisecond: 0 }, 1173 | }, 1174 | end: { 1175 | knownValues: { 1176 | year: 2020, month: 1, day: 1, hour: 0, minute: 0, second: 0, 1177 | }, 1178 | impliedValues: { millisecond: 0 }, 1179 | }, 1180 | }); 1181 | expect(res.start.date().toISOString()).toEqual('2019-12-25T00:00:00.000Z'); 1182 | expect(res.end.date().toISOString()).toEqual('2020-01-01T00:00:00.000Z'); 1183 | 1184 | // This test to make sure the WSD does not effect last month 1185 | res = parse('last 2 month', new Date('2019-02-09T02:14:05Z'), { weekStartDay: WEEKDAYS.Wednesday }); 1186 | expect(res).toMatchObject({ 1187 | start: { 1188 | knownValues: { 1189 | year: 2018, month: 12, day: 1, hour: 0, minute: 0, second: 0, 1190 | }, 1191 | impliedValues: { millisecond: 0 }, 1192 | }, 1193 | end: { 1194 | knownValues: { 1195 | year: 2019, month: 2, day: 1, hour: 0, minute: 0, second: 0, 1196 | }, 1197 | impliedValues: { millisecond: 0 }, 1198 | }, 1199 | }); 1200 | expect(res.start.date().toISOString()).toEqual('2018-12-01T00:00:00.000Z'); 1201 | expect(res.end.date().toISOString()).toEqual('2019-02-01T00:00:00.000Z'); 1202 | 1203 | // This test to make sure the WSD does not effect the last year 1204 | res = parse('last year', new Date('2020-02-29T02:14:05Z'), { weekStartDay: WEEKDAYS.Wednesday }); 1205 | expect(res).toMatchObject({ 1206 | start: { 1207 | knownValues: { 1208 | year: 2019, month: 1, day: 1, hour: 0, minute: 0, second: 0, 1209 | }, 1210 | impliedValues: { millisecond: 0 }, 1211 | }, 1212 | end: { 1213 | knownValues: { 1214 | year: 2020, month: 1, day: 1, hour: 0, minute: 0, second: 0, 1215 | }, 1216 | impliedValues: { millisecond: 0 }, 1217 | }, 1218 | }); 1219 | expect(res.start.date().toISOString()).toEqual('2019-01-01T00:00:00.000Z'); 1220 | expect(res.end.date().toISOString()).toEqual('2020-01-01T00:00:00.000Z'); 1221 | 1222 | res = parse('tue last week', new Date('2019-12-26T02:14:05Z'), { weekStartDay: WEEKDAYS.Wednesday }); 1223 | expect(res).toMatchObject({ 1224 | start: { 1225 | knownValues: { 1226 | year: 2019, month: 12, day: 24, hour: 0, minute: 0, second: 0, 1227 | }, 1228 | impliedValues: { millisecond: 0 }, 1229 | }, 1230 | end: { 1231 | knownValues: { 1232 | year: 2019, month: 12, day: 25, hour: 0, minute: 0, second: 0, 1233 | }, 1234 | impliedValues: { millisecond: 0 }, 1235 | }, 1236 | }); 1237 | expect(res.start.date().toISOString()).toEqual('2019-12-24T00:00:00.000Z'); 1238 | expect(res.end.date().toISOString()).toEqual('2019-12-25T00:00:00.000Z'); 1239 | 1240 | res = parse('tue next 2 weeks', new Date('2019-12-26T02:14:05Z'), { weekStartDay: WEEKDAYS.Wednesday }); 1241 | expect(res).toMatchObject({ 1242 | start: { 1243 | knownValues: { 1244 | year: 2020, month: 1, day: 14, hour: 0, minute: 0, second: 0, 1245 | }, 1246 | impliedValues: { millisecond: 0 }, 1247 | }, 1248 | end: { 1249 | knownValues: { 1250 | year: 2020, month: 1, day: 15, hour: 0, minute: 0, second: 0, 1251 | }, 1252 | impliedValues: { millisecond: 0 }, 1253 | }, 1254 | }); 1255 | expect(res.start.date().toISOString()).toEqual('2020-01-14T00:00:00.000Z'); 1256 | expect(res.end.date().toISOString()).toEqual('2020-01-15T00:00:00.000Z'); 1257 | 1258 | res = parse('mon next week', new Date('2019-12-26T02:14:05Z'), { weekStartDay: WEEKDAYS.Sunday }); 1259 | expect(res).toMatchObject({ 1260 | start: { 1261 | knownValues: { 1262 | year: 2019, month: 12, day: 30, hour: 0, minute: 0, second: 0, 1263 | }, 1264 | impliedValues: { millisecond: 0 }, 1265 | }, 1266 | end: { 1267 | knownValues: { 1268 | year: 2019, month: 12, day: 31, hour: 0, minute: 0, second: 0, 1269 | }, 1270 | impliedValues: { millisecond: 0 }, 1271 | }, 1272 | }); 1273 | expect(res.start.date().toISOString()).toEqual('2019-12-30T00:00:00.000Z'); 1274 | expect(res.end.date().toISOString()).toEqual('2019-12-31T00:00:00.000Z'); 1275 | 1276 | res = parse('sat next week', new Date('2019-12-26T02:14:05Z'), { weekStartDay: WEEKDAYS.Sunday }); 1277 | expect(res).toMatchObject({ 1278 | start: { 1279 | knownValues: { 1280 | year: 2020, month: 1, day: 4, hour: 0, minute: 0, second: 0, 1281 | }, 1282 | impliedValues: { millisecond: 0 }, 1283 | }, 1284 | end: { 1285 | knownValues: { 1286 | year: 2020, month: 1, day: 5, hour: 0, minute: 0, second: 0, 1287 | }, 1288 | impliedValues: { millisecond: 0 }, 1289 | }, 1290 | }); 1291 | expect(res.start.date().toISOString()).toEqual('2020-01-04T00:00:00.000Z'); 1292 | expect(res.end.date().toISOString()).toEqual('2020-01-05T00:00:00.000Z'); 1293 | 1294 | res = parse('sat next week', new Date('2019-12-26T02:14:05Z'), { weekStartDay: WEEKDAYS.Saturday }); 1295 | expect(res).toMatchObject({ 1296 | start: { 1297 | knownValues: { 1298 | year: 2019, month: 12, day: 28, hour: 0, minute: 0, second: 0, 1299 | }, 1300 | impliedValues: { millisecond: 0 }, 1301 | }, 1302 | end: { 1303 | knownValues: { 1304 | year: 2019, month: 12, day: 29, hour: 0, minute: 0, second: 0, 1305 | }, 1306 | impliedValues: { millisecond: 0 }, 1307 | }, 1308 | }); 1309 | expect(res.start.date().toISOString()).toEqual('2019-12-28T00:00:00.000Z'); 1310 | expect(res.end.date().toISOString()).toEqual('2019-12-29T00:00:00.000Z'); 1311 | 1312 | res = parse('thu next week', new Date('2019-12-26T02:14:05Z'), { weekStartDay: WEEKDAYS.Thursday }); 1313 | expect(res).toMatchObject({ 1314 | start: { 1315 | knownValues: { 1316 | year: 2020, month: 1, day: 2, hour: 0, minute: 0, second: 0, 1317 | }, 1318 | impliedValues: { millisecond: 0 }, 1319 | }, 1320 | end: { 1321 | knownValues: { 1322 | year: 2020, month: 1, day: 3, hour: 0, minute: 0, second: 0, 1323 | }, 1324 | impliedValues: { millisecond: 0 }, 1325 | }, 1326 | }); 1327 | expect(res.start.date().toISOString()).toEqual('2020-01-02T00:00:00.000Z'); 1328 | expect(res.end.date().toISOString()).toEqual('2020-01-03T00:00:00.000Z'); 1329 | 1330 | res = parse('thu last week', new Date('2019-12-26T02:14:05Z'), { weekStartDay: WEEKDAYS.Friday }); 1331 | expect(res).toMatchObject({ 1332 | start: { 1333 | knownValues: { 1334 | year: 2019, month: 12, day: 19, hour: 0, minute: 0, second: 0, 1335 | }, 1336 | impliedValues: { millisecond: 0 }, 1337 | }, 1338 | end: { 1339 | knownValues: { 1340 | year: 2019, month: 12, day: 20, hour: 0, minute: 0, second: 0, 1341 | }, 1342 | impliedValues: { millisecond: 0 }, 1343 | }, 1344 | }); 1345 | expect(res.start.date().toISOString()).toEqual('2019-12-19T00:00:00.000Z'); 1346 | expect(res.end.date().toISOString()).toEqual('2019-12-20T00:00:00.000Z'); 1347 | 1348 | res = parse('last week begin', new Date('2021-05-10T22:14:05Z'), { weekStartDay: WEEKDAYS.Tuesday }); 1349 | expect(res).toMatchObject({ 1350 | start: { 1351 | knownValues: { 1352 | year: 2021, month: 4, day: 27, hour: 0, minute: 0, second: 0, 1353 | }, 1354 | impliedValues: { millisecond: 0 }, 1355 | }, 1356 | end: { 1357 | knownValues: { 1358 | year: 2021, month: 4, day: 28, hour: 0, minute: 0, second: 0, 1359 | }, 1360 | impliedValues: { millisecond: 0 }, 1361 | }, 1362 | }); 1363 | expect(res.start.date().toISOString()).toEqual('2021-04-27T00:00:00.000Z'); 1364 | expect(res.end.date().toISOString()).toEqual('2021-04-28T00:00:00.000Z'); 1365 | 1366 | res = parse('last week end', new Date('2021-05-10T22:14:05Z'), { weekStartDay: WEEKDAYS.Tuesday }); 1367 | expect(res).toMatchObject({ 1368 | start: { 1369 | knownValues: { 1370 | year: 2021, month: 5, day: 3, hour: 0, minute: 0, second: 0, 1371 | }, 1372 | impliedValues: { millisecond: 0 }, 1373 | }, 1374 | end: { 1375 | knownValues: { 1376 | year: 2021, month: 5, day: 4, hour: 0, minute: 0, second: 0, 1377 | }, 1378 | impliedValues: { millisecond: 0 }, 1379 | }, 1380 | }); 1381 | expect(res.start.date().toISOString()).toEqual('2021-05-03T00:00:00.000Z'); 1382 | expect(res.end.date().toISOString()).toEqual('2021-05-04T00:00:00.000Z'); 1383 | 1384 | res = parse('last week end', new Date('2021-05-10T22:14:05Z'), { weekStartDay: WEEKDAYS.Tuesday, timezoneOffset: 480 }); 1385 | expect(res).toMatchObject({ 1386 | start: { 1387 | knownValues: { 1388 | year: 2021, month: 5, day: 10, hour: 0, minute: 0, second: 0, 1389 | }, 1390 | impliedValues: { millisecond: 0, timezoneOffset: 480 }, 1391 | }, 1392 | end: { 1393 | knownValues: { 1394 | year: 2021, month: 5, day: 11, hour: 0, minute: 0, second: 0, 1395 | }, 1396 | impliedValues: { millisecond: 0, timezoneOffset: 480 }, 1397 | }, 1398 | }); 1399 | expect(res.start.date().toISOString()).toEqual('2021-05-09T16:00:00.000Z'); 1400 | expect(res.end.date().toISOString()).toEqual('2021-05-10T16:00:00.000Z'); 1401 | }); 1402 | 1403 | it('raises error when weekStartDay is invalid', () => { 1404 | expect(() => parse('this mon', new Date(), { weekStartDay: 'ahihi' })).toThrowError(/invalid weekStartDay/i); 1405 | }); 1406 | 1407 | it('default inputs should work', () => { 1408 | const res = parseV1('last week', new Date('2021-10-13T18:00:00Z')); 1409 | expect(res.start.date().toISOString()).toEqual('2021-10-04T00:00:00.000Z'); 1410 | expect(res.end.date().toISOString()).toEqual('2021-10-11T00:00:00.000Z'); 1411 | }); 1412 | }); 1413 | --------------------------------------------------------------------------------