@becollective/utils 1.10.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.semaphore/utils.yml +1 -1
- package/index.ts +7 -0
- package/lib/FeatureFlag.d.ts +12 -0
- package/lib/FeatureFlag.js +51 -0
- package/lib/constants/allowedHtmlTags.d.ts +7 -0
- package/lib/constants/allowedHtmlTags.js +8 -0
- package/lib/constants/avatarColours.d.ts +35 -0
- package/lib/constants/avatarColours.js +36 -0
- package/lib/constants/countries.d.ts +253 -0
- package/lib/constants/countries.js +254 -0
- package/lib/constants/currencies.d.ts +29 -0
- package/lib/constants/currencies.js +36 -0
- package/lib/constants/customFields.d.ts +11 -0
- package/lib/constants/customFields.js +12 -0
- package/lib/constants/distancesUnits.d.ts +11 -0
- package/lib/constants/distancesUnits.js +13 -0
- package/lib/constants/groups.d.ts +13 -0
- package/lib/constants/groups.js +85 -0
- package/lib/constants/insuranceStatuses.d.ts +15 -0
- package/lib/constants/insuranceStatuses.js +7 -0
- package/lib/constants/insuranceTypes.d.ts +35 -0
- package/lib/constants/insuranceTypes.js +36 -0
- package/lib/constants/languages.d.ts +2 -0
- package/lib/constants/languages.js +194 -0
- package/lib/constants/locales.d.ts +119 -0
- package/lib/constants/locales.js +191 -0
- package/lib/constants/notifications.d.ts +47 -0
- package/lib/constants/notifications.js +31 -0
- package/lib/constants/opportunities.d.ts +72 -0
- package/lib/constants/opportunities.js +82 -0
- package/lib/constants/qualificationTypes.d.ts +4 -0
- package/lib/constants/qualificationTypes.js +18 -0
- package/lib/constants/rules.d.ts +13 -0
- package/lib/constants/rules.js +21 -0
- package/lib/constants/subscriptions.d.ts +9 -0
- package/lib/constants/subscriptions.js +10 -0
- package/lib/constants/surveys.d.ts +16 -0
- package/lib/constants/surveys.js +17 -0
- package/lib/constants.d.ts +1 -0
- package/lib/constants.js +76 -0
- package/lib/date-time.d.ts +28 -0
- package/lib/date-time.js +79 -0
- package/lib/forms.d.ts +1 -0
- package/lib/forms.js +728 -0
- package/lib/locality.d.ts +5 -0
- package/lib/locality.js +15 -0
- package/lib/money.d.ts +7 -0
- package/lib/money.js +32 -0
- package/lib/opportunity.d.ts +1 -0
- package/lib/opportunity.js +11 -0
- package/lib/opportunityUser.d.ts +8 -0
- package/lib/opportunityUser.js +29 -0
- package/lib/password.d.ts +8 -0
- package/lib/password.js +43 -0
- package/lib/rrule/date.d.ts +4 -0
- package/lib/rrule/date.js +30 -0
- package/lib/rrule/index.d.ts +2 -0
- package/lib/rrule/index.js +18 -0
- package/lib/rrule/parsing.d.ts +6 -0
- package/lib/rrule/parsing.js +130 -0
- package/lib/rrule/parsing.test.d.ts +1 -0
- package/lib/rrule/parsing.test.js +11 -0
- package/lib/rrule.d.ts +49 -0
- package/lib/rrule.js +303 -0
- package/lib/src/FeatureFlag.d.ts +12 -0
- package/lib/src/FeatureFlag.js +51 -0
- package/lib/src/date-time.d.ts +28 -0
- package/lib/src/date-time.js +79 -0
- package/lib/src/forms.d.ts +1 -0
- package/lib/src/forms.js +728 -0
- package/lib/src/locality.d.ts +5 -0
- package/lib/src/locality.js +15 -0
- package/lib/src/money.d.ts +7 -0
- package/lib/src/money.js +32 -0
- package/lib/src/opportunity.d.ts +1 -0
- package/lib/src/opportunity.js +11 -0
- package/lib/src/opportunityUser.d.ts +8 -0
- package/lib/src/opportunityUser.js +29 -0
- package/lib/src/password.d.ts +8 -0
- package/lib/src/password.js +43 -0
- package/package.json +15 -29
- package/src/FeatureFlag.ts +26 -10
- package/src/constants/allowedHtmlTags.ts +6 -0
- package/src/constants/avatarColours.ts +34 -0
- package/src/constants/countries.ts +252 -0
- package/src/constants/currencies.ts +33 -0
- package/src/constants/customFields.ts +10 -0
- package/src/constants/distancesUnits.ts +11 -0
- package/src/constants/groups.ts +83 -0
- package/src/constants/insuranceStatuses.ts +5 -0
- package/src/constants/insuranceTypes.ts +34 -0
- package/src/constants/languages.ts +192 -0
- package/src/constants/locales.ts +189 -0
- package/src/constants/notifications.ts +30 -0
- package/src/constants/opportunities.ts +84 -0
- package/src/constants/qualificationTypes.ts +17 -0
- package/src/constants/rules.ts +22 -0
- package/src/constants/subscriptions.ts +8 -0
- package/src/constants/surveys.ts +15 -0
- package/src/constants.ts +40 -0
- package/src/{date-time.js → date-time.ts} +18 -11
- package/src/forms.ts +807 -0
- package/src/{opportunityUser.js → opportunityUser.ts} +5 -1
- package/src/{password.js → password.ts} +2 -0
- package/src/rrule/date.ts +25 -0
- package/src/rrule/index.ts +2 -0
- package/src/rrule/parsing.ts +136 -0
- package/src/rrule.ts +367 -0
- package/tests/{date-time.test.js → date-time.test.ts} +48 -46
- package/tests/featureFlag.test.ts +36 -39
- package/tests/forms.test-data.json +43 -0
- package/tests/forms.test-schema.json +144 -0
- package/tests/forms.test-uischema.json +117 -0
- package/tests/forms.test.ts +674 -0
- package/tests/{money.test.js → money.test.ts} +2 -3
- package/tests/{password.test.js → password.test.ts} +7 -6
- package/tests/rrule/parsing.test.ts +12 -0
- package/tests/rrule.test.ts +625 -0
- package/tsconfig.json +15 -12
- package/bundle.js +0 -270
- package/index.js +0 -25
- package/rollup.config.js +0 -25
- /package/src/{locality.js → locality.ts} +0 -0
- /package/src/{money.js → money.ts} +0 -0
- /package/src/{opportunity.js → opportunity.ts} +0 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { DateTime } from 'luxon';
|
|
2
|
+
import rrule from 'rrule';
|
|
3
|
+
|
|
4
|
+
import { toRruleObj } from './parsing';
|
|
5
|
+
|
|
6
|
+
// Given a start (Date object) and a duration in milliseconds, return a new Date object specifying the end time
|
|
7
|
+
export const getEndTime = (start, duration) => new Date(start.getTime() + duration);
|
|
8
|
+
|
|
9
|
+
// Combine a date and time string in formats: YYYYMMDD, HHss
|
|
10
|
+
export const munge = (date, time) => {
|
|
11
|
+
const datePart = DateTime.fromISO(date).toFormat('YYYYMMDD');
|
|
12
|
+
return `${datePart}${time}`;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Return a date object representing the given date and time string in the timezone specified
|
|
16
|
+
export const transpose = (date, time, timezone) => {
|
|
17
|
+
return DateTime.fromISO(munge(date, time)).setZone(timezone).toDate();
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Gets the start time (HHmm format) in the timezone specified for an rrule
|
|
21
|
+
export const getStartTime = (rule, timezone) => {
|
|
22
|
+
// eslint-disable-next-line no-undef
|
|
23
|
+
const obj: any = typeof rule === 'object' ? rrule : toRruleObj(rule);
|
|
24
|
+
return DateTime.fromISO(obj.DTSTART).setZone(timezone).toFormat('HHmm');
|
|
25
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { DateTime } from 'luxon';
|
|
2
|
+
|
|
3
|
+
const omitBy = (obj, predicate) => {
|
|
4
|
+
return Object.fromEntries(
|
|
5
|
+
Object.entries(obj).filter(([key, value]) => !predicate(value, key))
|
|
6
|
+
);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const toRruleObj = function toRruleObj(str) {
|
|
10
|
+
const config = (str || '')
|
|
11
|
+
.split(';')
|
|
12
|
+
.map((component) => component.split('=', 2))
|
|
13
|
+
.reduce((all, [k, v]) => {
|
|
14
|
+
all[k] = v;
|
|
15
|
+
return all;
|
|
16
|
+
}, {});
|
|
17
|
+
|
|
18
|
+
return omitBy(config, (value, key) => !key || value === null);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const toRruleStr = function toRruleStr(obj) {
|
|
22
|
+
const filter = Object.keys(obj).filter((k) => obj[k] !== null && obj[k] !== undefined)
|
|
23
|
+
const map = filter.map((key) => `${key}=${obj[key]}`)
|
|
24
|
+
const join = map.join(';');
|
|
25
|
+
return Object.keys(obj)
|
|
26
|
+
.filter((k) => obj[k] !== null && obj[k] !== undefined)
|
|
27
|
+
.map((key) => `${key}=${obj[key]}`)
|
|
28
|
+
.join(';');
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const TOKEN_TYPES = {
|
|
32
|
+
SIGN: 'SIGN',
|
|
33
|
+
DAYS: 'DAYS',
|
|
34
|
+
TIME_START: 'TIME_START',
|
|
35
|
+
HOURS: 'HOURS',
|
|
36
|
+
MINUTES: 'MINUTES',
|
|
37
|
+
SECONDS: 'SECONDS',
|
|
38
|
+
WEEKS: 'WEEKS',
|
|
39
|
+
UNKNOWN: 'UNKNOWN',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type token = {
|
|
43
|
+
type: string;
|
|
44
|
+
val: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const parseDuration = (duration) => {
|
|
48
|
+
// --- lexer
|
|
49
|
+
// follows https://tools.ietf.org/html/rfc5545#section-3.3.6
|
|
50
|
+
let windowStart = 0;
|
|
51
|
+
const tokens: token[] = [];
|
|
52
|
+
for (let windowEnd = 1; windowEnd <= duration.length; windowEnd++) {
|
|
53
|
+
const token = duration.slice(windowStart, windowEnd);
|
|
54
|
+
if (/^[+-]?P$/.test(token)) {
|
|
55
|
+
const val = token[0] === '-' ? -1 : 1;
|
|
56
|
+
tokens.push({ type: TOKEN_TYPES.SIGN, val: val });
|
|
57
|
+
windowStart = windowEnd;
|
|
58
|
+
}
|
|
59
|
+
else if (/^[0-9]*W$/.test(token)) {
|
|
60
|
+
const val = parseInt(token.slice(0, -1), 10);
|
|
61
|
+
tokens.push({ type: TOKEN_TYPES.WEEKS, val: val });
|
|
62
|
+
windowStart = windowEnd;
|
|
63
|
+
}
|
|
64
|
+
else if (/^[0-9]*D$/.test(token)) {
|
|
65
|
+
const val = parseInt(token.slice(0, -1), 10);
|
|
66
|
+
tokens.push({ type: TOKEN_TYPES.DAYS, val: val });
|
|
67
|
+
windowStart = windowEnd;
|
|
68
|
+
}
|
|
69
|
+
else if (token === 'T') {
|
|
70
|
+
tokens.push({ type: TOKEN_TYPES.TIME_START, val: -1 });
|
|
71
|
+
windowStart = windowEnd;
|
|
72
|
+
}
|
|
73
|
+
else if (/^[0-9]*\.?[0-9]*H$/.test(token)) {
|
|
74
|
+
const val = parseFloat(token.slice(0, -1));
|
|
75
|
+
tokens.push({ type: TOKEN_TYPES.HOURS, val: val });
|
|
76
|
+
windowStart = windowEnd;
|
|
77
|
+
}
|
|
78
|
+
else if (/^[0-9]*M$/.test(token)) {
|
|
79
|
+
const val = parseInt(token.slice(0, -1), 10);
|
|
80
|
+
tokens.push({ type: TOKEN_TYPES.MINUTES, val: val });
|
|
81
|
+
windowStart = windowEnd;
|
|
82
|
+
}
|
|
83
|
+
else if (/^[0-9]*S$/.test(token)) {
|
|
84
|
+
const val = parseInt(token.slice(0, -1), 10);
|
|
85
|
+
tokens.push({ type: TOKEN_TYPES.SECONDS, val: val });
|
|
86
|
+
windowStart = windowEnd;
|
|
87
|
+
}
|
|
88
|
+
else if (windowEnd === duration.length) {
|
|
89
|
+
tokens.push({ type: TOKEN_TYPES.UNKNOWN, val: token });
|
|
90
|
+
windowStart = windowEnd;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// --- parser
|
|
95
|
+
// this parser is very permissive and doesn't care about token order, repetitions, etc.
|
|
96
|
+
let parsedDurationValue: number = 0;
|
|
97
|
+
// for token type of hours, because we made it support fractions, apply parseInt
|
|
98
|
+
// to round it up to integer
|
|
99
|
+
for (const token of tokens) {
|
|
100
|
+
if (token.type === TOKEN_TYPES.SIGN) parsedDurationValue = token.val;
|
|
101
|
+
else if (token.type === TOKEN_TYPES.WEEKS) parsedDurationValue = token.val * 7 * 24 * 60 * 60 * 1000;
|
|
102
|
+
else if (token.type === TOKEN_TYPES.DAYS) parsedDurationValue = token.val * 24 * 60 * 60 * 1000;
|
|
103
|
+
else if (token.type === TOKEN_TYPES.HOURS) parsedDurationValue = token.val * 60 * 60 * 1000, 10;
|
|
104
|
+
else if (token.type === TOKEN_TYPES.MINUTES) parsedDurationValue = token.val * 60 * 1000;
|
|
105
|
+
else if (token.type === TOKEN_TYPES.SECONDS) parsedDurationValue = token.val * 1000;
|
|
106
|
+
// TODO check if we use this, MILLISECONDS is no longer supported
|
|
107
|
+
// else if (token.type === TOKEN_TYPES.MILLISECONDS) parsedDurationValue = token.val;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return parsedDurationValue;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export const getDuration = (icalObj) => {
|
|
114
|
+
const duration = icalObj.DURATION;
|
|
115
|
+
|
|
116
|
+
// make it v1 backward compatible
|
|
117
|
+
const dtend = icalObj.DTEND;
|
|
118
|
+
const dtstart = icalObj.DTSTART;
|
|
119
|
+
|
|
120
|
+
if (duration && dtend) {
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (duration) {
|
|
125
|
+
return parseDuration(duration);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (dtstart && dtend) {
|
|
129
|
+
const start = DateTime.fromISO(dtstart);
|
|
130
|
+
const end = DateTime.fromISO(dtend);
|
|
131
|
+
const difference = end.diff(start);
|
|
132
|
+
if (!difference.isValid) return 0;
|
|
133
|
+
return difference.toMillis();
|
|
134
|
+
}
|
|
135
|
+
return 0;
|
|
136
|
+
};
|
package/src/rrule.ts
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { RRule, rrulestr } from 'rrule';
|
|
2
|
+
import { DateTime } from 'luxon';
|
|
3
|
+
import { toRruleObj, toRruleStr, getDuration, getEndTime, getStartTime } from './rrule/index';
|
|
4
|
+
import { transpose } from './rrule/date';
|
|
5
|
+
|
|
6
|
+
type ToFrom = {
|
|
7
|
+
from: Date,
|
|
8
|
+
to: Date,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const isEmpty = (obj) => {
|
|
12
|
+
if (Array.isArray(obj) || typeof obj === 'string') {
|
|
13
|
+
return obj.length === 0;
|
|
14
|
+
}
|
|
15
|
+
return !obj;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const omit = (obj, keys) => {
|
|
19
|
+
return Object.fromEntries(
|
|
20
|
+
Object.entries(obj).filter(([key]) => !keys.includes(key))
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const omitBy = (obj, predicate) => {
|
|
25
|
+
return Object.fromEntries(
|
|
26
|
+
Object.entries(obj).filter(([key, value]) => !predicate(value, key))
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const logError = (...args) => {
|
|
31
|
+
const logger = console.error || console.log;
|
|
32
|
+
logger(...args);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export { toRruleObj, toRruleStr };
|
|
36
|
+
|
|
37
|
+
// Customised recurrenceRule parser for BeCollective
|
|
38
|
+
// We store DURATION in our rules, which are not supported by rrule
|
|
39
|
+
// We need to return this data and also remove it from our rrule configuration before parsing it
|
|
40
|
+
export const parse = function parse(str) {
|
|
41
|
+
const icalObj = toRruleObj(str);
|
|
42
|
+
const duration = getDuration(icalObj);
|
|
43
|
+
// TODO delete unused?
|
|
44
|
+
// const until = icalObj.UNTIL;
|
|
45
|
+
const dtend = icalObj.DTEND;
|
|
46
|
+
const nondurationItems = omit(icalObj, ['DURATION', 'DTEND']);
|
|
47
|
+
const rrule = rrulestr(toRruleStr(omitBy(nondurationItems, isEmpty)));
|
|
48
|
+
return { duration, durationString: icalObj.DURATION, rrule, dtend };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const validateRruleConfig = (obj) => {
|
|
52
|
+
const config = obj || {};
|
|
53
|
+
const REQUIRED_FIELDS = [
|
|
54
|
+
{ field: 'DTSTART', name: 'Start time' },
|
|
55
|
+
{ field: 'RRULE:FREQ', name: 'Frequency (Month/Weekly)' },
|
|
56
|
+
{ field: 'INTERVAL', name: 'Interval' },
|
|
57
|
+
{ field: 'UNTIL', name: 'Until' },
|
|
58
|
+
{ field: 'Duration', name: 'Duration' },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const errors: string[] = [];
|
|
62
|
+
|
|
63
|
+
REQUIRED_FIELDS.forEach((fieldConfig) => {
|
|
64
|
+
if (!config[fieldConfig.field]) {
|
|
65
|
+
errors.push(`Please enter a ${fieldConfig.name}`);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
if (config.DTSTART && config.UNTIL && DateTime.fromISO(config.DTSTART) > DateTime.fromISO(config.UNTIL)) {
|
|
70
|
+
errors.push('DTSTART must be earlier than or equal to UNTIL');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return errors;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const validateRruleConfigV2 = (bcRruleInstance) => {
|
|
77
|
+
const rruleInstance = bcRruleInstance.rrule;
|
|
78
|
+
const REQUIRED_RRULE_FIELDS = [
|
|
79
|
+
{ field: 'dtstart', name: 'Start time' },
|
|
80
|
+
{ field: 'interval', name: 'Interval' },
|
|
81
|
+
{ field: 'until', name: 'Until' },
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
const REQUIRED_BC_FIELDS = [{ field: 'duration', name: 'Duration' }];
|
|
85
|
+
|
|
86
|
+
const REQUIRED_BC_FREQ = {
|
|
87
|
+
field: 'freq',
|
|
88
|
+
name: 'Frequency (Month/Weekly/Yearly)',
|
|
89
|
+
values: [RRule.YEARLY, RRule.WEEKLY, RRule.MONTHLY],
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const errors: string[] = [];
|
|
93
|
+
|
|
94
|
+
REQUIRED_RRULE_FIELDS.forEach((fieldConfig) => {
|
|
95
|
+
if (!rruleInstance.options[fieldConfig.field]) {
|
|
96
|
+
errors.push(`Please enter a ${fieldConfig.name}`);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
if (!REQUIRED_BC_FREQ.values.includes(rruleInstance.options[REQUIRED_BC_FREQ.field])) {
|
|
101
|
+
errors.push(`Please enter a ${REQUIRED_BC_FREQ.name}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
REQUIRED_BC_FIELDS.forEach((fieldConfig) => {
|
|
105
|
+
if (!bcRruleInstance[fieldConfig.field]) {
|
|
106
|
+
errors.push(`Please enter a ${fieldConfig.name}`);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (
|
|
111
|
+
rruleInstance.options.dtstart &&
|
|
112
|
+
rruleInstance.options.until &&
|
|
113
|
+
DateTime.fromISO(rruleInstance.options.dtstart) > DateTime.fromISO(rruleInstance.options.until)
|
|
114
|
+
) {
|
|
115
|
+
errors.push('DTSTART must be earlier than or equal to UNTIL');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return errors;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* a helper function that put offical rrule and becollective rrule fields together
|
|
123
|
+
* @param {Object} rruleInstance an RRule instance.
|
|
124
|
+
* @param {*} duration non rrule standard duration field.
|
|
125
|
+
*/
|
|
126
|
+
export const buildBcRruleString = (rruleInstance, duration = '0H') => {
|
|
127
|
+
let rruleString = rruleInstance.toString();
|
|
128
|
+
if (duration) {
|
|
129
|
+
rruleString = `${rruleString};DURATION=${duration}`;
|
|
130
|
+
}
|
|
131
|
+
const bcRrule = parse(rruleString);
|
|
132
|
+
const errors = validateRruleConfigV2(bcRrule);
|
|
133
|
+
if (errors.length === 0) {
|
|
134
|
+
return rruleString;
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
throw new Error(JSON.stringify(errors));
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export const isValidRrule = (obj) => validateRruleConfig(obj).length === 0;
|
|
142
|
+
|
|
143
|
+
/* === TIMEZONE ISSUES WITH RRULE ===
|
|
144
|
+
|
|
145
|
+
The rrule library does not store, use or account for timezone. The timezone information is lost and this leads to bugs displaying times and with
|
|
146
|
+
generating recurrences. This happens because rrule determines the start time by interpreting DTSTART in the timezone of the runtime
|
|
147
|
+
environment. This is fine when the runtime and an opportunity share the same timezone. However, inconsistencies occur when they
|
|
148
|
+
differ, and daylight savings is introduced.
|
|
149
|
+
|
|
150
|
+
INCONSISTENT START & END TIMES
|
|
151
|
+
-------------------------------------------
|
|
152
|
+
We want to specify a start and end time (5pm - 7pm)
|
|
153
|
+
Technically we need to express this start and end time as UTC timestamps (2017-07-03 T07:00 - 2017-07-03 T09:00)
|
|
154
|
+
|
|
155
|
+
This presents a problem when we interpret rrule strings to display and generate recurrences.
|
|
156
|
+
|
|
157
|
+
Consider that we want a recurrenceRule to apply for ‘Asia/Taipei (+0800 UTC)’ and start at 1500 hours, we would have the following
|
|
158
|
+
UTC timestamp:
|
|
159
|
+
|
|
160
|
+
2017-07-03 T07:00
|
|
161
|
+
|
|
162
|
+
If our runtime is using Australia/Melbourne time, rrule would determine the start time of each recurrence to be at T17:00
|
|
163
|
+
|
|
164
|
+
In Asia/Taipei time, that is T15:00 which is correct
|
|
165
|
+
|
|
166
|
+
However, fast forward to October when daylight savings kicks in for Australia/Melbourne, and suddenly T17:00 translates to T14:00
|
|
167
|
+
in Asia/Taipei.
|
|
168
|
+
|
|
169
|
+
As illustrated above, we need to workaround the rrule library by incorporating timezone information in the way we display and
|
|
170
|
+
generate recurrences.
|
|
171
|
+
|
|
172
|
+
OFF BY ONE DATES
|
|
173
|
+
———————————
|
|
174
|
+
This bug occurs because the start time is determined using runtime’s timezone. When finding recurrences between a set of dates,
|
|
175
|
+
the correct dates and times are found for the runtime’s local timezone. But when the client uses these dates, the timezone
|
|
176
|
+
conversion could cause the date to change.
|
|
177
|
+
|
|
178
|
+
Example generated start time: 2017-10-10 T20:30 UTC
|
|
179
|
+
If a Melbourne client interprets this timestamp, it would translate to: 2017-10-11 T:07:30 AEST
|
|
180
|
+
|
|
181
|
+
So time is correct but date is WRONG
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
--- SUMMARY & TLDR ---;
|
|
185
|
+
The rrule spec causes us to lose timezone information and leads to inconsistent interpretations of DTSTART and UNTIL times. This is
|
|
186
|
+
further complicated by the fact that a rule is parsed and interpreted based on code runtime’s timezone, which means we are
|
|
187
|
+
potentially shuffling and converting things between 3 timezones: UTC -> runtime -> opportunity. Hence we must implement several
|
|
188
|
+
“tricks” so that we ignore the time portion from dates and the date portion from times.
|
|
189
|
+
|
|
190
|
+
***********************************************************/
|
|
191
|
+
|
|
192
|
+
// Return correct and consistent human readable values for a recurrenceRule
|
|
193
|
+
// We need to ignore DTSTART date component and use only the time component so start time is updated according to daylight
|
|
194
|
+
// savings
|
|
195
|
+
export const recurrenceToText = (recurrenceRule, timezone, isFlexible = false) => {
|
|
196
|
+
const TIME_FORMAT = 'h:mm a';
|
|
197
|
+
try {
|
|
198
|
+
const { duration, rrule: rule } = parse(recurrenceRule);
|
|
199
|
+
|
|
200
|
+
const dtstartToTimezone = DateTime.fromJSDate(rule.origOptions.dtstart as Date).setZone(timezone);
|
|
201
|
+
const untilToTimezone = DateTime.fromJSDate(rule.origOptions.until as Date).setZone(timezone);
|
|
202
|
+
const rrule = new RRule({
|
|
203
|
+
...rule.origOptions,
|
|
204
|
+
dtstart:
|
|
205
|
+
DateTime.utc(
|
|
206
|
+
dtstartToTimezone.year,
|
|
207
|
+
dtstartToTimezone.month,
|
|
208
|
+
dtstartToTimezone.day,
|
|
209
|
+
dtstartToTimezone.hour,
|
|
210
|
+
dtstartToTimezone.minute,
|
|
211
|
+
dtstartToTimezone.second
|
|
212
|
+
)
|
|
213
|
+
.toJSDate(),
|
|
214
|
+
tzid: timezone,
|
|
215
|
+
until:
|
|
216
|
+
DateTime.utc(
|
|
217
|
+
untilToTimezone.year,
|
|
218
|
+
untilToTimezone.month,
|
|
219
|
+
untilToTimezone.day,
|
|
220
|
+
untilToTimezone.hour,
|
|
221
|
+
untilToTimezone.minute,
|
|
222
|
+
untilToTimezone.second
|
|
223
|
+
)
|
|
224
|
+
.toJSDate(),
|
|
225
|
+
});
|
|
226
|
+
if (isFlexible) {
|
|
227
|
+
const hours = duration / 1000 / 60 / 60;
|
|
228
|
+
let ruleText = rrule.toText();
|
|
229
|
+
if (rule.origOptions.count === 1) {
|
|
230
|
+
// When it is not a repeating rule
|
|
231
|
+
ruleText = ruleText.replace(/every year until/i, 'in total by');
|
|
232
|
+
ruleText = ruleText.replace(/ \(~ approximate\)/i, '');
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
text: `${hours} hours ${ruleText}`,
|
|
236
|
+
timezone,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
// Get today's start time in the recurrenceRule's timezone, then convert that timestamp to our local time
|
|
240
|
+
const end = dtstartToTimezone.plus(duration);
|
|
241
|
+
return {
|
|
242
|
+
text: rrule.toText().charAt(0).toUpperCase() + rrule.toText().slice(1),
|
|
243
|
+
time: `${dtstartToTimezone.toFormat(TIME_FORMAT)} to ${end.toFormat(TIME_FORMAT)}`,
|
|
244
|
+
timezone,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
catch (e) {
|
|
248
|
+
return { text: '', time: '', timezone };
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// Return the text based on the specified timezone
|
|
253
|
+
// This method doesn't convert rrule to it's owner opportunity's timezone
|
|
254
|
+
export const recurrenceToTextInTimezone = (recurrenceRule, timezone) => {
|
|
255
|
+
const TIME_FORMAT = 'h:mm A';
|
|
256
|
+
|
|
257
|
+
const localTimezone = timezone || 'UTC';
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
const { duration, rrule: rule } = parse(recurrenceRule);
|
|
261
|
+
const start = DateTime.fromISO(getStartTime(recurrenceRule, localTimezone), 'HHmm').setZone(localTimezone);
|
|
262
|
+
const end = DateTime.fromISO(getEndTime(start.toJSDate(), duration)).setZone(localTimezone);
|
|
263
|
+
return {
|
|
264
|
+
text: rule.toText().charAt(0).toUpperCase() + rule.toText().slice(1),
|
|
265
|
+
time: `${start.toFormat(TIME_FORMAT)} to ${end.toFormat(TIME_FORMAT)}`,
|
|
266
|
+
localTimezone,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
catch (e) {
|
|
270
|
+
return { text: '', time: '', localTimezone };
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
export const recurrencesBetween = (fromDate, toDate, recurrenceRule, timezone) => {
|
|
275
|
+
try {
|
|
276
|
+
// We need to deal with time and dates separately because timestamps will be converted and lead to errors
|
|
277
|
+
const time = getStartTime(recurrenceRule, timezone);
|
|
278
|
+
const { duration, rrule: rule } = parse(recurrenceRule);
|
|
279
|
+
const recurrences = rule.between(fromDate, toDate);
|
|
280
|
+
|
|
281
|
+
return recurrences.map((start) => {
|
|
282
|
+
// eslint-disable-next-line no-undef
|
|
283
|
+
const fixed = transpose(start, time, timezone);
|
|
284
|
+
return {
|
|
285
|
+
from: fixed,
|
|
286
|
+
to: getEndTime(fixed, duration),
|
|
287
|
+
};
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
catch (e) {
|
|
291
|
+
logError('Error while generating recurrences between', fromDate.toString(), 'to', toDate.toString());
|
|
292
|
+
logError('RRULE:', recurrenceRule, 'TIMEZONE:', timezone);
|
|
293
|
+
logError('***\n\n', e, '\n\n***');
|
|
294
|
+
return [];
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
*
|
|
300
|
+
* @param {*} rrule
|
|
301
|
+
* @param {*} timezone
|
|
302
|
+
* @param {*} from
|
|
303
|
+
* @param {*} isFlexible
|
|
304
|
+
* @returns an array of date in true utc (DST offset is considered)
|
|
305
|
+
*/
|
|
306
|
+
export const recurrencesBetweenV2 = (rrule, timezone, from = new Date(), isFlexible = false) => {
|
|
307
|
+
const { duration, rrule: rule } = parse(rrule);
|
|
308
|
+
|
|
309
|
+
const dtstartToTimezone = DateTime.fromJSDate(rule.origOptions.dtstart).setZone(timezone);
|
|
310
|
+
const untilToTimezone = DateTime.fromJSDate(rule.origOptions.until).setZone(timezone);
|
|
311
|
+
const rruleToConvert = new RRule({
|
|
312
|
+
...rule.origOptions,
|
|
313
|
+
dtstart:
|
|
314
|
+
DateTime.utc(
|
|
315
|
+
dtstartToTimezone.year,
|
|
316
|
+
dtstartToTimezone.month,
|
|
317
|
+
dtstartToTimezone.day,
|
|
318
|
+
dtstartToTimezone.hour,
|
|
319
|
+
dtstartToTimezone.minute,
|
|
320
|
+
dtstartToTimezone.second
|
|
321
|
+
).toJSDate(),
|
|
322
|
+
tzid: timezone,
|
|
323
|
+
until:
|
|
324
|
+
DateTime.utc(
|
|
325
|
+
untilToTimezone.year,
|
|
326
|
+
untilToTimezone.month,
|
|
327
|
+
untilToTimezone.day,
|
|
328
|
+
untilToTimezone.hour,
|
|
329
|
+
untilToTimezone.minute,
|
|
330
|
+
untilToTimezone.second
|
|
331
|
+
).toJSDate(),
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const occurrences = rruleToConvert
|
|
335
|
+
.all()
|
|
336
|
+
.map((date) => DateTime.fromJSDate(date).toUTC().setZone('local', { keepLocalTime: true }));
|
|
337
|
+
|
|
338
|
+
if (!occurrences.length) {
|
|
339
|
+
return [];
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const output: ToFrom[] = [];
|
|
343
|
+
if(isFlexible) {
|
|
344
|
+
let i = 0;
|
|
345
|
+
occurrences.map(o => {
|
|
346
|
+
output.push({ from: o.toJSDate(), to: o.toJSDate() });
|
|
347
|
+
// update "to" field
|
|
348
|
+
if (i > 0) {
|
|
349
|
+
output[i - 1].to = o.toUTC().minus({ seconds: 1 }).toJSDate();
|
|
350
|
+
}
|
|
351
|
+
if (i === occurrences.length - 1) {
|
|
352
|
+
output[i].to = DateTime.fromJSDate(rule.options.until as Date).toUTC().minus({ seconds: 1 }).toJSDate();
|
|
353
|
+
}
|
|
354
|
+
i++;
|
|
355
|
+
});
|
|
356
|
+
return output.filter(((o: ToFrom) => from ? DateTime.fromJSDate(o.to) > DateTime.fromJSDate(from) : DateTime.fromJSDate(o.to) < DateTime.fromJSDate(rule.options.until as Date)));
|
|
357
|
+
} else {
|
|
358
|
+
const output: ToFrom[] = [];
|
|
359
|
+
occurrences.map(o => {
|
|
360
|
+
output.push({
|
|
361
|
+
from: o.toJSDate(),
|
|
362
|
+
to: o.toUTC().plus(duration).toJSDate(),
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
return output.filter(((o: ToFrom) => from ? DateTime.fromJSDate(o.to) > DateTime.fromJSDate(from) : DateTime.fromJSDate(o.to) < DateTime.fromJSDate(rule.options.until as Date))) as { from: Date, to: Date }[];
|
|
366
|
+
}
|
|
367
|
+
};
|