diff --git a/.gitignore b/.gitignore index 7894e39..951f559 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ # IDE .idea +.vscode # npm node_modules diff --git a/src/plugin/duration/index.js b/src/plugin/duration/index.js new file mode 100644 index 0000000..835456b --- /dev/null +++ b/src/plugin/duration/index.js @@ -0,0 +1,174 @@ +import { MILLISECONDS_A_WEEK, MILLISECONDS_A_DAY, MILLISECONDS_A_HOUR, MILLISECONDS_A_MINUTE, MILLISECONDS_A_SECOND } from '../../constant' + +const MILLISECONDS_A_YEAR = MILLISECONDS_A_DAY * 365 +const MILLISECONDS_A_MONTH = MILLISECONDS_A_DAY * 30 + +const durationRegex = /^(-|\+)?P(?:([-+]?[0-9,.]*)Y)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)W)?(?:([-+]?[0-9,.]*)D)?(?:T(?:([-+]?[0-9,.]*)H)?(?:([-+]?[0-9,.]*)M)?(?:([-+]?[0-9,.]*)S)?)?$/ + +const unitToMS = { + years: MILLISECONDS_A_YEAR, + months: MILLISECONDS_A_MONTH, + days: MILLISECONDS_A_DAY, + hours: MILLISECONDS_A_HOUR, + minutes: MILLISECONDS_A_MINUTE, + seconds: MILLISECONDS_A_SECOND, + weeks: MILLISECONDS_A_WEEK +} + +const isDuration = d => (d instanceof Duration) // eslint-disable-line no-use-before-define + +let $d +let $u + +const wrapper = (input, instance, unit) => + new Duration(input, unit, instance.$l) // eslint-disable-line no-use-before-define + +const prettyUnit = unit => `${$u.p(unit)}s` + +class Duration { + constructor(input, unit, locale) { + this.$d = {} + this.$l = locale || 'en' + if (unit) { + return wrapper(input * unitToMS[prettyUnit(unit)], this) + } + if (typeof input === 'number') { + this.$ms = input + this.parseFromMilliseconds() + return this + } + if (typeof input === 'object') { + Object.keys(input).forEach((k) => { + this.$d[prettyUnit(k)] = input[k] + }) + this.calMilliseconds() + return this + } + if (typeof input === 'string') { + const d = input.match(durationRegex) + if (d) { + [,, + this.$d.years, this.$d.months,, + this.$d.days, this.$d.hours, this.$d.minutes, this.$d.seconds] = d + this.calMilliseconds() + return this + } + } + return this + } + + calMilliseconds() { + this.$ms = Object.keys(this.$d).reduce((total, unit) => ( + total + ((this.$d[unit] || 0) * (unitToMS[unit] || 1)) + ), 0) + } + + parseFromMilliseconds() { + let { $ms } = this + this.$d.years = Math.floor($ms / MILLISECONDS_A_YEAR) + $ms %= MILLISECONDS_A_YEAR + this.$d.months = Math.floor($ms / MILLISECONDS_A_MONTH) + $ms %= MILLISECONDS_A_MONTH + this.$d.days = Math.floor($ms / MILLISECONDS_A_DAY) + $ms %= MILLISECONDS_A_DAY + this.$d.hours = Math.floor($ms / MILLISECONDS_A_HOUR) + $ms %= MILLISECONDS_A_HOUR + this.$d.minutes = Math.floor($ms / MILLISECONDS_A_MINUTE) + $ms %= MILLISECONDS_A_MINUTE + this.$d.seconds = $ms / MILLISECONDS_A_SECOND + } + + toISOString() { + const Y = this.$d.years ? `${this.$d.years}Y` : '' + const M = this.$d.months ? `${this.$d.months}M` : '' + let days = this.$d.days || 0 + if (this.$d.weeks) { + days += this.$d.weeks * 7 + } + const D = days ? `${days}D` : '' + const H = this.$d.hours ? `${this.$d.hours}H` : '' + const m = this.$d.minutes ? `${this.$d.minutes}M` : '' + let seconds = this.$d.seconds || 0 + if (this.$d.milliseconds) { + seconds += this.$d.milliseconds / 1000 + } + const S = seconds ? `${seconds}S` : '' + const T = (H || M || S) ? 'T' : '' + const result = `P${Y}${M}${D}${T}${H}${m}${S}` + return result === 'P' ? 'P0D' : result + } + + toJSON() { + return this.toISOString() + } + + as(unit) { + return this.$ms / (unitToMS[prettyUnit(unit)] || 1) + } + + get(unit) { + let base = this.$ms + const pUnit = prettyUnit(unit) + if (pUnit === 'milliseconds') { + base %= 1000 + } else { + base = Math.floor(base / unitToMS[pUnit]) + } + return base + } + + add(input, unit, isSubtract) { + let another + if (unit) { + another = input * unitToMS[prettyUnit(unit)] + } else if (isDuration(input)) { + another = input.$ms + } else { + another = wrapper(input, this).$ms + } + return wrapper(this.$ms + (another * (isSubtract ? -1 : 1)), this) + } + + subtract(input, unit) { + return this.add(input, unit, true) + } + + locale(l) { + const that = this.clone() + that.$l = l + return that + } + + clone() { + return wrapper(this.$ms, this) + } + + humanize(withSuffix) { + return $d().add(this.$ms, 'ms').locale(this.$l).fromNow(!withSuffix) + } + + milliseconds() { return this.get('milliseconds') } + asMilliseconds() { return this.as('milliseconds') } + seconds() { return this.get('seconds') } + asSeconds() { return this.as('seconds') } + minutes() { return this.get('minutes') } + asMinutes() { return this.as('minutes') } + hours() { return this.get('hours') } + asHours() { return this.as('hours') } + days() { return this.get('days') } + asDays() { return this.as('days') } + weeks() { return this.get('weeks') } + asWeeks() { return this.as('weeks') } + months() { return this.get('months') } + asMonths() { return this.as('months') } + years() { return this.get('years') } + asYears() { return this.as('years') } +} +export default (option, Dayjs, dayjs) => { + $d = dayjs + $u = dayjs().$utils() + dayjs.duration = function (input, unit) { + return wrapper(input, {}, unit) + } + dayjs.isDuration = isDuration +} diff --git a/test/plugin/duration.test.js b/test/plugin/duration.test.js new file mode 100644 index 0000000..41be206 --- /dev/null +++ b/test/plugin/duration.test.js @@ -0,0 +1,181 @@ +import MockDate from 'mockdate' +import dayjs from '../../src' +import duration from '../../src/plugin/duration' +import relativeTime from '../../src/plugin/relativeTime' +import '../../src/locale/fr' +import '../../src/locale/es' + +dayjs.extend(relativeTime) +dayjs.extend(duration) + +beforeEach(() => { + MockDate.set(new Date()) +}) + +afterEach(() => { + MockDate.reset() +}) + +describe('Creating', () => { + it('milliseconds', () => { + expect(dayjs.duration(100).toISOString()).toBe('PT0.1S') + expect(dayjs.duration(1000).toISOString()).toBe('PT1S') + }) + it('two argument will bubble up to the next', () => { + expect(dayjs.duration(59, 'seconds').toISOString()).toBe('PT59S') + expect(dayjs.duration(60, 'seconds').toISOString()).toBe('P1M') + expect(dayjs.duration(13213, 'seconds').toISOString()).toBe('PT3H40M13S') + }) + it('object with float', () => { + expect(dayjs.duration({ + seconds: 1, + minutes: 2, + hours: 3, + days: 4, + months: 6, + years: 7 + }).toISOString()).toBe('P7Y6M4DT3H2M1S') + }) + it('object with weeks and float', () => { + expect(dayjs.duration({ + seconds: 1.1, + minutes: 2, + hours: 3, + days: 4, + weeks: 5, + months: 6, + years: 7 + }).toISOString()).toBe('P7Y6M39DT3H2M1.1S') + }) + it('object with millisecond', () => { + expect(dayjs.duration({ + ms: 1 + }).toISOString()).toBe('PT0.001S') + }) +}) + + +describe('Parse ISO string', () => { + it('Full ISO string', () => { + expect(dayjs.duration('P7Y6M4DT3H2M1S').toISOString()).toBe('P7Y6M4DT3H2M1S') + }) + it('Part ISO string', () => { + expect(dayjs.duration('PT2777H46M40S').toISOString()).toBe('PT2777H46M40S') + }) + it('Invalid ISO string', () => { + expect(dayjs.duration('Invalid').toISOString()).toBe('P0D') + }) +}) + +it('Is duration', () => { + expect(dayjs.isDuration(dayjs.duration())).toBe(true) + expect(dayjs.isDuration(dayjs.duration(1))).toBe(true) + expect(dayjs.isDuration(dayjs())).toBe(false) + expect(dayjs.isDuration({})).toBe(false) + expect(dayjs.isDuration()).toBe(false) +}) + +it('toJSON', () => { + expect(JSON.stringify({ + postDuration: dayjs.duration(5, 'minutes') + })).toBe('{"postDuration":"P5M"}') +}) + +describe('Humanize', () => { + it('Humaniz', () => { + expect(dayjs.duration(1, 'minutes').humanize()).toBe('a minute') + expect(dayjs.duration(2, 'minutes').humanize()).toBe('2 minutes') + expect(dayjs.duration(24, 'hours').humanize()).toBe('a day') + expect(dayjs.duration(1, 'minutes').humanize(true)).toBe('in a minute') + expect(dayjs.duration(-1, 'minutes').humanize(true)).toBe('a minute ago') + }) + + it('Locale', () => { + expect(dayjs.duration(1, 'minutes').humanize(true)).toBe('in a minute') + expect(dayjs.duration(1, 'minutes').locale('fr').humanize(true)).toBe('dans une minute') + expect(dayjs.duration(1, 'minutes').locale('es').humanize(true)).toBe('en un minuto') + }) +}) + +describe('Clone', () => { + it('Locale clone', () => { + const d = dayjs.duration(1, 'minutes').locale('fr') + const r = 'dans une minute' + expect(d.humanize(true)).toBe(r) + expect(d.clone().humanize(true)).toBe(r) + }) +}) + +describe('Milliseconds', () => { + expect(dayjs.duration(500).milliseconds()).toBe(500) + expect(dayjs.duration(1500).milliseconds()).toBe(500) + expect(dayjs.duration(15000).milliseconds()).toBe(0) + expect(dayjs.duration(500).asMilliseconds()).toBe(500) + expect(dayjs.duration(1500).asMilliseconds()).toBe(1500) + expect(dayjs.duration(15000).asMilliseconds()).toBe(15000) +}) + +describe('Add', () => { + const a = dayjs.duration(1, 'days') + const b = dayjs.duration(2, 'days') + expect(a.add(b).days()).toBe(3) + expect(a.add(1, 'days').days()).toBe(2) + expect(a.add({ days: 5 }).days()).toBe(6) +}) + +describe('Subtract', () => { + const a = dayjs.duration(3, 'days') + const b = dayjs.duration(2, 'days') + expect(a.subtract(b).days()).toBe(1) +}) + + +describe('Seconds', () => { + expect(dayjs.duration(500).seconds()).toBe(0) + expect(dayjs.duration(1500).seconds()).toBe(1) + expect(dayjs.duration(15000).seconds()).toBe(15) + expect(dayjs.duration(500).asSeconds()).toBe(0.5) + expect(dayjs.duration(1500).asSeconds()).toBe(1.5) + expect(dayjs.duration(15000).asSeconds()).toBe(15) +}) + +describe('Minutes', () => { + expect(dayjs.duration(100000).minutes()).toBe(1) + expect(dayjs.duration(100000).asMinutes().toFixed(2)).toBe('1.67') +}) + +describe('Hours', () => { + expect(dayjs.duration(10000000).hours()).toBe(2) + expect(dayjs.duration(10000000).asHours().toFixed(2)).toBe('2.78') +}) + +describe('Days', () => { + expect(dayjs.duration(100000000).days()).toBe(1) + expect(dayjs.duration(100000000).asDays().toFixed(2)).toBe('1.16') +}) + +describe('Weeks', () => { + expect(dayjs.duration(1000000000).weeks()).toBe(1) + expect(dayjs.duration(1000000000).asWeeks().toFixed(2)).toBe('1.65') +}) + +describe('Month', () => { + expect(dayjs.duration(10000000000).months()).toBe(3) + expect(dayjs.duration({ months: 3 }).asMonths()).toBe(3) +}) + +describe('Years', () => { + expect(dayjs.duration(100000000000).years()).toBe(3) + expect(dayjs.duration(100000000000).asYears().toFixed(2)).toBe('3.17') +}) + +describe('prettyUnit', () => { + const d = dayjs.duration(2, 's') + expect(d.toISOString()).toBe('PT2S') + expect(d.as('Second')).toBe(2) + expect(d.get('s')).toBe(2) + expect(dayjs.duration({ + M: 12, + m: 12 + }).toISOString()).toBe('P12MT12M') +}) diff --git a/types/plugin/duration.d.ts b/types/plugin/duration.d.ts new file mode 100644 index 0000000..38225c9 --- /dev/null +++ b/types/plugin/duration.d.ts @@ -0,0 +1,58 @@ +import { PluginFunc } from 'dayjs' + +declare const plugin: PluginFunc +export = plugin + +type DurationInputType = string | number | object +type DurationAddType = number | object | Duration + +declare class Duration { + constructor (input: DurationInputType, unit?: string, locale?: string) + + clone(): Duration + + humanize(withSuffix: boolean): string + + milliseconds(): number + asMilliseconds(): number + + seconds(): number + asSeconds(): number + + minutes(): number + asMinutes(): number + + hours(): number + asHours(): number + + days(): number + asDays(): number + + weeks(): number + asWeeks(): number + + months(): number + asMonths(): number + + years(): number + asYears(): number + + as(unit: string): number + + get(unit: string): number + + add(input: DurationAddType, unit? : string): Duration + + subtract(input: DurationAddType, unit? : string): Duration + + toJSON(): string + + toISOString(): string + + locale(locale: string): Duration +} + +declare module 'dayjs' { + export function duration(input?: DurationInputType , unit?: string): Duration + export function isDuration(d: any): d is Duration +}