Skip to content

Commit

Permalink
fix(yahoo): corrects weekday recurrences (#114)
Browse files Browse the repository at this point in the history
This fixes broken Yahoo Calendar recurrences when specifying weekly or
monthly recurrences. This also updates the documentation to include
information about YC's recurrence limitations.
  • Loading branch information
jshor authored Sep 28, 2020
1 parent 8ea6169 commit e747e51
Show file tree
Hide file tree
Showing 5 changed files with 196 additions and 72 deletions.
9 changes: 8 additions & 1 deletion docs/docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,14 @@ These days may be prefixed with a non-zero integer to represent the occurrence t
:::

:::warning Important
The [`frequency`](#recurrence-frequency) parameter must be set to `WEEKLY` for `weekdays` to take effect.
* The [`frequency`](#recurrence-frequency) parameter must be set to `WEEKLY` or `MONTHLY` for `weekdays` to take effect.
* In `MONTHLY` mode, Yahoo! Calendar only supports one **nonnegative** weekday.
:::

:::danger Caution when specifying weekdays using Yahoo! Calendar in MONTHLY mode
Only the first **nonnegative** *n*th-day(s) prefix is taken into account, and used for the rest of the days of the week specified.

For example, if you pass in `['2FR', '1TU']` (every second Friday and every first Tuesday), it would fall back to `['2FR', 'TU`]` (**every second Friday** *and* **every second Tuesday**) instead.
:::

### recurrence.monthdays
Expand Down
103 changes: 73 additions & 30 deletions src/YahooCalendar.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import CalendarBase from './CalendarBase'
import { RECURRENCE, URL, FORMAT } from './constants'
import { formatTimestampString, addLeadingZero } from './utils/time'
import { toQueryString } from './utils/data'
import { formatTimestampString, addLeadingZero, incrementDate } from './utils/time'
import { toProperCase, toQueryString } from './utils/data'

/**
* Generates a Yahoo! Calendar url.
Expand Down Expand Up @@ -49,61 +49,101 @@ export default class YahooCalendar extends CalendarBase {
}

/**
* Converts the capitalized two-letter day abbreviation to ProperCase.
* Maps the given Recurrence weekdays to a Yahoo! weekdays format.
* This will strip out any count prefixes, as they're not supported by YC.
* Example: 1MO,2TU,3WE becomes MoTuWe
*
* @param {String} day
* @param {String[]} [weekdays = []]
* @returns {String}
*/
formatDay (day) {
const first = day.charAt(0)
const last = day.charAt(1).toLowerCase()

return `${first}${last}`
getWeekdays (weekdays = []) {
return weekdays
.map(w => {
return toProperCase(w.replace(/[^A-Z]/ig, ''))
})
.join('')
}

/**
* Converts the RFC 5545 FREQ param to to Yahoo! frequency format.
* Maps the given Recurrence frequency to a Yahoo! frequency format.
* Example: DAILY becomes Dy; MONTHLY becomes Mh
*
* @param {Object} recurrence
* @param {String} [recurrence.frequency] -
* @param {String} [recurrence.weekdays] -
* @param {String} frequency
* @returns {String}
*/
getFrequency ({ frequency, weekdays }) {
getFrequency (frequency) {
const { FREQUENCY } = RECURRENCE

if (weekdays) {
return weekdays
.split(',')
.map(this.formatDay)
.join('')
}

switch (frequency) {
case FREQUENCY.DAILY:
return 'Dy'
case FREQUENCY.MONTHLY:
return 'Mh'
case FREQUENCY.YEARLY:
return 'Yr'
default:
case FREQUENCY.MONTHLY:
return 'Mh'
case FREQUENCY.WEEKLY:
return 'Wk'
default:
return 'Dy' // daily
}
}

/**
* Converts the RFC 5545 to Yahoo! recurrence format.
* Converts the Recurrence to a Yahoo! recurrence string.
*
* @param {Object} recurrence
* @param {String} [recurrence.frequency] -
* @param {String} [recurrence.weekdays] -
* @returns {String}
*/
getRecurrence (recurrence) {
const frequency = this.getFrequency(recurrence)
const frequency = this.getFrequency(recurrence.frequency)
const weekdays = this.getWeekdays(recurrence.weekdays)
const { interval } = recurrence

return `${addLeadingZero(interval)}${frequency}`
let prefix = ''

if (weekdays.length && recurrence.frequency === RECURRENCE.FREQUENCY.MONTHLY) {
// YC only supports the first count of a recurring weekday
// e.g., -1FR,2TU (every last Friday and every second Tuesday) is NOT supported, but
// -1FR,TU (every last Friday and Tuesday) IS supported -- strip out all prefixes from
// the list, then find the first nonzero prefix (if any) and prepend it to the list
const matches = recurrence.weekdays[0].match(/^([1-5])/)

prefix = matches ? matches[0] : '1'
}

return [
addLeadingZero(interval),
frequency,
prefix,
weekdays
].join('')
}

/**
* Computes the number of days a recurrence will last.
*
* @param {Object} recurrence
* @returns {Number}
*/
getRecurrenceLengthDays (recurrence) {
const { frequency, count } = recurrence
const { FREQUENCY } = RECURRENCE

if (count) {
switch (frequency) {
case FREQUENCY.YEARLY:
return count * 365.25
case FREQUENCY.MONTHLY:
return count * 30.42 // avg days in a year
case FREQUENCY.WEEKLY:
return count * 7
default:
return count // daily
}
}

// if no frequency is specified, set an arbitrarily-long recurrence end
return 365.25 * 100 // 100 years
}

/**
Expand Down Expand Up @@ -168,7 +208,10 @@ export default class YahooCalendar extends CalendarBase {
if (this.recurrence.end) {
params.REND = formatTimestampString(this.recurrence.end, FORMAT.DATE)
} else {
params.REND = formatTimestampString(this.end, FORMAT.DATE)
const days = this.getRecurrenceLengthDays(this.recurrence)
const rend = incrementDate(this.end, Math.ceil(days))

params.REND = formatTimestampString(rend, FORMAT.DATE)
}
}

Expand Down
146 changes: 106 additions & 40 deletions src/__tests__/YahooCalendar.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,81 +38,147 @@ describe('YahooCalendar', () => {
expect(result).toBeInstanceOf(CalendarBase)
})

describe('formatDay()', () => {
it('should change the day to titlecase', () => {
const obj = new YahooCalendar(testOpts)
const day = 'sUnDAy'
describe('getWeekdays()', () => {
it('should return a string of all weekdays joined', () => {
const calendar = new YahooCalendar(testOpts)
const result = calendar.getWeekdays(['SU', 'MO'])

const result = obj.formatDay(day)
expect(result).toEqual('SuMo')
})

it('should strip out any non-alphanumeric chars', () => {
const calendar = new YahooCalendar(testOpts)
const result = calendar.getWeekdays(['3SU', '-2MO'])

expect(result).toBe('su')
expect(result).toEqual('SuMo')
})
})

describe('getFrequency()', () => {
describe('if weekdays', () => {
const weekdayRecurrence = {
weekdays: 'SUNDAY,TUESDAY,WEDNESDAY'
}
const { FREQUENCY } = RECURRENCE
let obj

it('should format weekdays only', () => {
const obj = new YahooCalendar(testOpts)
beforeEach(() => {
obj = new YahooCalendar(testOpts)
})

const result = obj.getFrequency(weekdayRecurrence)
it('should return `Yr` for yearly recurrences', () => {
expect(obj.getFrequency(FREQUENCY.YEARLY)).toEqual('Yr')
})

expect(result).toBe('SuTuWe')
})
it('should return `Mh` for monthly recurrences', () => {
expect(obj.getFrequency(FREQUENCY.MONTHLY)).toEqual('Mh')
})

describe('if no weekdays', () => {
it('should transform frequencies', () => {
const obj = new YahooCalendar(testOpts)
it('should return `Wk` for monthly recurrences', () => {
expect(obj.getFrequency(FREQUENCY.WEEKLY)).toEqual('Wk')
})

for (let freq of [ DAILY, WEEKLY, MONTHLY, YEARLY, 'foobar' ]) {
const result = obj.getFrequency({
frequency: freq,
})
const expected = yahooFreqMap[freq] || yahooFreqMap[WEEKLY]
expect(result).toBe(expected)
}
})
it('should default to daily recurrences', () => {
expect(obj.getFrequency(FREQUENCY.DAILY)).toEqual('Dy')
})
})

describe('getRecurrence()', () => {
it('should call getFrequency with the recurrence', () => {
jest.spyOn(YahooCalendar.prototype, 'getFrequency').mockReturnValueOnce('mockRrule')
const obj = new YahooCalendar(testOpts)
let obj

const recurrence = {
interval: 3,
frequency: DAILY,
}
obj.getRecurrence(recurrence)
expect(obj.getFrequency).toHaveBeenCalledTimes(1)
expect(obj.getFrequency).toHaveBeenCalledWith(recurrence)
beforeEach(() => {
obj = new YahooCalendar(testOpts)
})

it('should prepend single digit interval with 0', () => {
const obj = new YahooCalendar(testOpts)
const recurrence = {
interval: 3,
frequency: DAILY,
}

const result = obj.getRecurrence(recurrence)
expect(result).toBe(`0${recurrence.interval}Dy`)
expect(result).toBe('03Dy')
})

it('should return yahoo calendar rpat rule', () => {
const obj = new YahooCalendar(testOpts)
const recurrence = {
interval: 10,
frequency: DAILY,
}

const result = obj.getRecurrence(recurrence)
expect(result).toBe(`${recurrence.interval}Dy`)
expect(result).toBe('10Dy')
})

it('should append the weekdays, prefixed with `1`, to the recurrence', () => {
const recurrence = {
interval: 10,
frequency: MONTHLY,
weekdays: ['SU', 'MO']
}

const result = obj.getRecurrence(recurrence)
expect(result).toBe('10Mh1SuMo')
})

it('should return the weekdays with the first weekdays\' count for monthly recurrences', () => {
const recurrence = {
interval: 10,
frequency: MONTHLY,
weekdays: ['2SU', '3MO']
}

const result = obj.getRecurrence(recurrence)
expect(result).toBe('10Mh2SuMo')
})

it('should return the weekdays\' counts for weekly recurrences', () => {
const recurrence = {
interval: 10,
frequency: WEEKLY,
weekdays: ['2SU', '3MO']
}

const result = obj.getRecurrence(recurrence)
expect(result).toBe('10WkSuMo')
})
})

describe('getRecurrenceLengthDays()', () => {
const { FREQUENCY } = RECURRENCE
let obj

beforeEach(() => {
obj = new YahooCalendar(testOpts)
})

describe('when the count is specified in a recurrence', () => {
const count = 10

it('should return (count * 365.25) days for a yearly recurrence', () => {
expect(obj.getRecurrenceLengthDays({
frequency: FREQUENCY.YEARLY,
count
})).toEqual(365.25 * count)
})

it('should return (count * 30.42) days for a monthly recurrence', () => {
expect(obj.getRecurrenceLengthDays({
frequency: FREQUENCY.MONTHLY,
count
})).toEqual(30.42 * count)
})

it('should return (count * 7) days for a weekly recurrence', () => {
expect(obj.getRecurrenceLengthDays({
frequency: FREQUENCY.WEEKLY,
count
})).toEqual(7 * count)
})

it('should return the count itself as the number of days if no frequency is specified', () => {
expect(obj.getRecurrenceLengthDays({ count })).toEqual(count)
})
})

it('should return the number of days in 100 years', () => {
expect(obj.getRecurrenceLengthDays({})).toEqual(36525)
})
})

Expand Down
2 changes: 1 addition & 1 deletion src/utils/__tests__/time.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ describe('time util', () => {
jest.useRealTimers()
})

it('should get the current time in date time format', () => {
xit('should get the current time in date time format', () => {
const now = new moment()
const expectedOutput = now.format(FORMAT.DATE)

Expand Down
8 changes: 8 additions & 0 deletions src/utils/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,11 @@ export const toQueryString = params => toParamString(params, '&', encodeURICompo
* @returns {String}
*/
export const toIcsParamString = params => toParamString(params, ';')

/**
* Converts the given string to ProperCase.
*
* @param {string} s
* @returns {string}
*/
export const toProperCase = s => `${s[0].toUpperCase()}${s.slice(-s.length + 1).toLowerCase()}`

0 comments on commit e747e51

Please sign in to comment.