moment-recur-ts
Version:
A momentjs plugin for matching and generating recurring dates.
290 lines (248 loc) • 8.51 kB
text/typescript
import * as moment from 'moment'
import { Rule } from './rule'
/**
* @internal
* @hidden
*/
export type CalendarMeasure =
'daysOfWeek'
| 'daysOfMonth'
| 'weeksOfMonth'
| 'weeksOfMonthByDay'
| 'weeksOfYear'
| 'monthsOfYear'
/**
* @internal
* @hidden
*/
type TimeUnits = 'day' | 'month' | 'week' | 'year'
/**
* Calendar object for creating and matching calendar-based rules
* @internal
* @hidden
*/
export class Calendar implements Rule {
private static readonly ranges: {
[key: string]: {
period: TimeUnits
range: TimeUnits
low: number
high: number
}
} = {
daysOfWeek: {
period: 'day',
range: 'week',
low: 0,
high: 6
},
daysOfMonth: {
period: 'day',
range: 'month',
low: 1,
high: 31
},
weeksOfMonth: {
period: 'week',
range: 'month',
low: 0,
high: 4
},
weeksOfMonthByDay: {
period: 'week',
range: 'month',
low: 0,
high: 4
},
weeksOfYear: {
period: 'week',
range: 'year',
low: 1,
high: 53
},
monthsOfYear: {
period: 'month',
range: 'year',
low: 0,
high: 11
}
}
public readonly units: number[]
public readonly measure: CalendarMeasure
private readonly range: TimeUnits
private readonly period: TimeUnits
constructor (units: (string | number)[], measure: CalendarMeasure) {
this.measure = measure
this.units = this.normalizeUnits(units)
this.range = Calendar.ranges[this.measure].range
this.period = Calendar.ranges[this.measure].period
}
public match (date: moment.Moment): boolean {
// Get the unit based on the required measure of the date
const unit = this.periodUnit(date)
// If the unit is in our list, return true, else return false
if (this.units.indexOf(unit) !== -1) {
return true
}
if ((this.units[0] === -1) &&
(unit === this.periodUnit(moment(date).endOf(this.range)))) {
return true
}
return false
}
public next (currentDateIn: moment.Moment, limit: moment.Moment): moment.Moment {
let currentDate = currentDateIn.clone()
// If still within our period, just give the next day
if (!this.isLastDayOfPeriod(currentDate)) {
const nextDateInPeriod = moment(currentDate).add(1, 'day')
if (this.match(nextDateInPeriod)) return nextDateInPeriod
}
while (true) {
// Get the next period based on the measure
const nextDate = this.nextPeriod(currentDate)
if (nextDate) {
return nextDate
} else {
// No more units found within this range,
// bump our range by one and try again.
currentDate = this.incrementRange(currentDate, 1)
// Check to see if next range starts on a valid period
if (this.match(currentDate)) {
return currentDate
}
if (currentDate.isSameOrAfter(limit)) {
throw new RangeError('Recurrence Year limit exceeded.')
}
}
}
}
public previous (currentDateIn: moment.Moment, limit: moment.Moment): moment.Moment {
let currentDate = currentDateIn.clone()
// If still within our period, just give the next day
if (!this.isFirstDayOfPeriod(currentDate)) {
const nextDateInPeriod = moment(currentDate).subtract(1, 'day')
if (this.match(nextDateInPeriod)) return nextDateInPeriod
}
while (true) {
// Get the next period based on the measure
const nextDate = this.previousPeriod(currentDate)
if (nextDate) {
return nextDate
} else {
// No more units found within this range,
// bump our range by one and try again.
currentDate = this.decrementRange(currentDate, 1)
// Check to see if next range starts on a valid period
if (this.match(currentDate)) {
return currentDate
}
if (currentDate.isSameOrBefore(limit)) {
throw new RangeError('Recurrence Year limit exceeded.')
}
}
}
}
private normalizeUnits (units: any[]): number[] {
const low = Calendar.ranges[this.measure].low
const high = Calendar.ranges[this.measure].high
return units.map(unitIn => {
if (unitIn === 'last') unitIn = -1
if (typeof unitIn !== 'number') {
// Convert day/month names to numbers, if needed
if (this.measure === 'daysOfWeek') {
unitIn = moment().set('days', unitIn).get('days')
} else if (this.measure === 'monthsOfYear') {
unitIn = moment().set('months', unitIn).get('months')
} else {
unitIn = +unitIn
}
}
if (!Number.isInteger(unitIn)) {
throw new TypeError('Invalid calendar unit in recurrence: ' + unitIn)
}
if ((unitIn < low || unitIn > high) && (unitIn !== -1)) {
throw new RangeError('Value should be in range ' + low + ' to ' + high)
}
return unitIn
}).sort((a, b) => a - b)
}
private periodUnit (date: moment.Moment): number
private periodUnit (date: moment.Moment, unit: number): moment.Moment
private periodUnit (date: moment.Moment, unit?: number): number | moment.Moment {
switch (this.measure) {
case 'daysOfWeek':
return date.day(unit!)
case 'daysOfMonth':
return date.date(unit!)
case 'weeksOfMonth':
return date.monthWeek(unit!)
case 'weeksOfMonthByDay':
return date.monthWeekByDay(unit!)
case 'weeksOfYear':
return date.week(unit!)
case 'monthsOfYear':
return date.month(unit!)
}
}
private nextPeriod (date: moment.Moment): moment.Moment | undefined {
// Get the next period based on the measure
const currentUnit = this.periodUnit(date)
const nextUnit = this.units
.map(unit => unit === -1 ? this.periodUnit(date.clone().endOf(this.range)) : unit)
.find(unit => unit > currentUnit)
if (nextUnit !== undefined) {
return this.periodUnit(date.clone(), nextUnit).startOf(this.period)
} else {
// Weeks do not follow orderly periods, e.g. a year can begin and end on week 1
if (this.measure === 'weeksOfYear' && (this.units.indexOf(1) !== -1)) {
return date.clone().endOf('year').startOf('week')
}
return undefined
}
}
private previousPeriod (date: moment.Moment): moment.Moment | undefined {
// Get the next period based on the measure
let currentUnit = this.periodUnit(date)
if (this.measure === 'weeksOfYear' && date.month() === 11 && date.week() === 1) currentUnit = 53
const nextUnit = this.units
.map(unit => unit === -1 ? this.periodUnit(date.clone().endOf(this.range)) : unit)
.reverse().find(unit => unit < currentUnit)
if (nextUnit !== undefined) {
if (this.measure === 'weeksOfYear' && currentUnit === 53) date.week(0)
return this.periodUnit(date.clone(), nextUnit).endOf(this.period)
} else {
// Weeks do not follow orderly periods, e.g. a year can begin and end on week 1
if (this.measure === 'weeksOfYear' &&
this.units.some(u => 52 >= u && u <= 53)) {
return date.clone().startOf('year').endOf('week')
}
return undefined
}
}
private incrementRange (date: moment.Moment, count: number): moment.Moment {
return date.add(count, this.range).startOf(this.range)
}
private decrementRange (date: moment.Moment, count: number): moment.Moment {
return date.subtract(count, this.range).endOf(this.range)
}
private isLastDayOfPeriod (date: moment.Moment): boolean {
if (this.measure === 'weeksOfMonthByDay') {
return date.monthWeekByDay() !== moment(date).add(1, 'day').monthWeekByDay()
}
if (this.period === 'day') {
return true
} else {
return date.isSame(moment(date).endOf(this.period))
}
}
private isFirstDayOfPeriod (date: moment.Moment): boolean {
if (this.measure === 'weeksOfMonthByDay') {
return date.monthWeekByDay() !== moment(date).subtract(1, 'day').monthWeekByDay()
}
if (this.period === 'day') {
return true
} else {
return date.isSame(moment(date).startOf(this.period))
}
}
}