diff --git a/force-app/volunteers/classes/DailyIterable.cls b/force-app/volunteers/classes/DailyIterable.cls new file mode 100644 index 0000000..c903b10 --- /dev/null +++ b/force-app/volunteers/classes/DailyIterable.cls @@ -0,0 +1,53 @@ +/* + * + * * Copyright (c) 2020, salesforce.com, inc. + * * All rights reserved. + * * SPDX-License-Identifier: BSD-3-Clause + * * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + * + */ + + public with sharing virtual class DailyIterable implements Iterator { + public RecurrenceRule rRule; + public Date runningDate; + public Integer runningCount; + + public DailyIterable(Date runningDate, RecurrenceRule rRule) { + this.runningDate = runningDate; + this.rRule = rRule; + this.runningCount = 0; + } + + public Boolean hasNext() { + if (rRule == null || runningDate == null) { + return false; + } + + Integer count = rRule.getCount(); + Date endDate = rRule.getEndDate(); + calculateRunningDate(); + + if (count == null && endDate == null) { + // If not present, and the COUNT rule part is also not present, + // the "RRULE" is considered to repeat forever. See: RFC5545 + return true; + } + + Boolean hasReachedCount = count != null && runningCount >= count; + Boolean hasReachedEndDate = endDate != null && runningDate > endDate; + + return !(hasReachedCount || hasReachedEndDate); + } + + public virtual void calculateRunningDate() { + runningDate = runningCount == 0 + ? runningDate + : runningDate.addDays(rRule.getInterval()); + } + + public Date next() { + runningCount++; + + return runningDate; + } +} diff --git a/force-app/volunteers/classes/DailyIterable.cls-meta.xml b/force-app/volunteers/classes/DailyIterable.cls-meta.xml new file mode 100644 index 0000000..7d5f9e8 --- /dev/null +++ b/force-app/volunteers/classes/DailyIterable.cls-meta.xml @@ -0,0 +1,5 @@ + + + 61.0 + Active + \ No newline at end of file diff --git a/force-app/volunteers/classes/MonthlyIterable.cls b/force-app/volunteers/classes/MonthlyIterable.cls new file mode 100644 index 0000000..b9b08e8 --- /dev/null +++ b/force-app/volunteers/classes/MonthlyIterable.cls @@ -0,0 +1,134 @@ +/* + * + * * Copyright (c) 2020, salesforce.com, inc. + * * All rights reserved. + * * SPDX-License-Identifier: BSD-3-Clause + * * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + * + */ + + public with sharing class MonthlyIterable extends DailyIterable { + private List byMonthDays; + Integer firstOccurrenceRunningDay; + + public MonthlyIterable(Date runningDate, RecurrenceRule rRule) { + super(runningDate, rRule); + + if (rRule == null || rRule.getByMonthDays() == null) { + return; + } + + byMonthDays = new List(rRule.getByMonthDays()); + byMonthDays.sort(); + } + + public override void calculateRunningDate() { + if (runningCount == 0 && rRule.getByDay() == null) { + firstOccurrenceRunningDay = runningDate.day(); + return; // Use first running date; + } + + Integer startDay; + + if (rRule.getBySetPos() != null && byMonthDays != null) { + //The startDay variable will hold the max value that is returned by rRule.getByMonthDays. + startDay = byMonthDays[byMonthDays.size() - 1]; + + if (startDay == 31) { + runningDate = runningDate.addDays(1) + .addMonths(rRule.getInterval()) + .addDays(-1); + } else { + runningDate = runningDate.addMonths(rRule.getInterval()) + .addDays(startDay - runningDate.day()); + } + } else if (rRule.getByDay() != null) { + calculateRecurrenceRunningDate(); + } else { + //The below value is defaulted to the day of the start date and is used to add days to runningDate below. + startDay = runningDate.day(); + + runningDate = runningDate.addMonths(rRule.getInterval()) + .addDays(startDay - runningDate.day()); + + if (firstOccurrenceRunningDay > startDay) { + runningDate = runningDate.addDays( + firstOccurrenceRunningDay - runningDate.day() + ); + } + } + } + + public String getRecurrenceWeek() { + String byDay = rRule.getByDay(); + String dayNumber = byDay.subString(1, 2); // This holds first or second + + return dayNumber; + } + + public String getWeekDayAbbr() { + String byDay = rRule.getByDay(); + String weekDayName = byDay.subString(2); //This holds the day name + return weekDayName; + } + + public Date getFirstOccurrenceOfMonth() { + Date firstWeekDayOccurrence; + // We need to see what month and get to the start of the month + Date startOfMonth = runningDate.toStartOfMonth(); + + // Check what day of the week the start of the month is i.e Tuesday, Wednesday + Integer startOfMonthDayNum = Util.getDayNum(startOfMonth); + + String recurrenceWeekDayAbbr = getWeekDayAbbr(); + Integer recurrenceWeekDayNum = rRule.getDayNum(recurrenceWeekDayAbbr) + 1; + if (recurrenceWeekDayNum == startOfMonthDayNum) { + firstWeekDayOccurrence = startOfMonth; + } else if (recurrenceWeekDayNum < startOfMonthDayNum) { + firstWeekDayOccurrence = startOfMonth.addDays( + 7 - (startOfMonthDayNum - recurrenceWeekDayNum) + ); + } else { + firstWeekDayOccurrence = startOfMonth.addDays( + recurrenceWeekDayNum - startOfMonthDayNum + ); + } + + return firstWeekDayOccurrence; + } + + public void calculateRecurrenceRunningDate() { + if (runningCount > 0) { + runningDate = runningDate.toStartOfMonth().addMonths(rRule.getInterval()); + } + + Integer monthlyRecurrenceNumber = Integer.valueOf(getRecurrenceWeek()); + Date firstOccurrenceOfMonth = getFirstOccurrenceOfMonth(); + + if (monthlyRecurrenceNumber == 1) { + runningDate = firstOccurrenceOfMonth; + } else { + Date tempDate = firstOccurrenceOfMonth.addDays( + (monthlyRecurrenceNumber - 1) * 7 + ); + + while ( + tempDate.month() > runningDate.month() || + tempDate.year() > runningDate.year() + ) { + if (runningCount == 0) { + runningDate = tempDate.toStartOfMonth(); + } else if (runningCount > 0) { + runningDate = runningDate.addMonths(rRule.getInterval()); + } + + firstOccurrenceOfMonth = getFirstOccurrenceOfMonth(); + tempDate = firstOccurrenceOfMonth.addDays( + (monthlyRecurrenceNumber - 1) * 7 + ); + } + + runningDate = tempDate; + } + } +} diff --git a/force-app/volunteers/classes/MonthlyIterable.cls-meta.xml b/force-app/volunteers/classes/MonthlyIterable.cls-meta.xml new file mode 100644 index 0000000..7d5f9e8 --- /dev/null +++ b/force-app/volunteers/classes/MonthlyIterable.cls-meta.xml @@ -0,0 +1,5 @@ + + + 61.0 + Active + \ No newline at end of file diff --git a/force-app/volunteers/classes/RecurrenceRule.cls b/force-app/volunteers/classes/RecurrenceRule.cls new file mode 100644 index 0000000..a4b8e17 --- /dev/null +++ b/force-app/volunteers/classes/RecurrenceRule.cls @@ -0,0 +1,342 @@ +/* + * + * * Copyright (c) 2020, salesforce.com, inc. + * * All rights reserved. + * * SPDX-License-Identifier: BSD-3-Clause + * * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + * + */ + + public with sharing class RecurrenceRule { + // some examples + // FREQ=DAILY;INTERVAL=3;UNTIL=20200925T000000Z + // FREQ=WEEKLY;INTERVAL=1;BYDAY=SU,MO,TU,WE,TH,FR,SA;UNTIL=20200925T000000Z + // FREQ=WEEKLY;INTERVAL=1;BYDAY=SU,MO,TU,WE,TH,FR,SA;COUNT=10 + // FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;COUNT=12 + + public enum Frequency { + DAILY, + WEEKLY, + MONTHLY + } + + public enum Day { + SU, + MO, + TU, + WE, + TH, + FR, + SA + } + + private static String LAST_DAY_OF_MONTH = 'LastDayOfMonth'; + @TestVisible + private String rRule; + private Frequency freq; + private Integer interval = 1; + private Date endDate; + private Integer count; + private List days; + private Integer bySetPos = -1; + private Set byMonthDays; + private String byDay; + + public RecurrenceRule reset() { + rRule = null; + freq = null; + interval = 1; + endDate = null; + count = null; + days = null; + bySetPos = -1; + byMonthDays = null; + byDay = null; + return this; + } + + public RecurrenceRule withFrequency(Frequency freq) { + this.freq = freq; + return this; + } + + public RecurrenceRule withFrequency(String freqString) { + setFreqFromString(freqString); + return this; + } + + public RecurrenceRule withInterval(Integer interval) { + if (interval != null) { + this.interval = interval; + } + return this; + } + + public RecurrenceRule withEndDate(Date endDate) { + this.endDate = endDate; + + return this; + } + + public RecurrenceRule withCount(Integer count) { + this.count = count; + return this; + } + + public RecurrenceRule withDays(List days) { + this.days = days; + return this; + } + + public RecurrenceRule withDays(List days) { + populateDays(days); + return this; + } + + public RecurrenceRule withByMonthDays(DateTime dateTimeStart) { + if (freq != Frequency.MONTHLY) { + return this; + } + + Set daysToAdd = new Set(); + + daysToAdd.addAll(new Set{ 28, 29, 30, 31 }); + + if (daysToAdd.size() != 0) { + byMonthDays = daysToAdd; + } + + return this; + } + + public RecurrenceRule withByDay(String byDay) { + // rRule spec is '+2SU' for 'second Sunday of the month', but our picklist just stores '2SU' for simplicity + if (byDay != LAST_DAY_OF_MONTH && byDay != null) { + this.byDay = '+' + byDay; + } else { + this.byDay = byDay; + } + + return this; + } + + public RecurrenceRule withRuleString(String rrString) { + parseAndValidate(rrString); + this.rRule = rrString; + return this; + } + + public Frequency getFrequency() { + return freq; + } + + public Integer getInterval() { + return interval; + } + + public Date getEndDate() { + return endDate; + } + + public Integer getCount() { + return count; + } + + public List getDays() { + return days; + } + + public Integer getBySetPos() { + return -1; + } + + public Set getByMonthDays() { + //This is to accomodate the Skip backward logic + //TODO: allow customers to opt out + + return byMonthDays; + } + + public String getByDay() { + return byDay; + } + + public List getDayNums() { + List result = new List(); + for (Day day : days) { + result.add(day.ordinal()); + } + return result; + } + + public Integer getDayNum(String dayAbbr) { + Integer result; + for (Day day : Day.values()) { + if (day.name().equalsIgnoreCase(dayAbbr)) { + result = day.ordinal(); + } + } + return result; + } + + public String build() { + List ruleParts = new List(); + + // FREQ (required) + ruleParts.add('FREQ=' + freq); + + // INTERVAL + if (interval > 1) { + ruleParts.add('INTERVAL=' + interval); + } + + // UNTIL + if (endDate != null) { + String endDateString = DateTime.newInstanceGMT( + endDate, + Time.newInstance(0, 0, 0, 0) + ) + .formatGmt('yyyyMMdd'); + + ruleParts.add('UNTIL=' + endDateString + 'T000000Z'); + } + + // COUNT + if (count != null) { + ruleParts.add('COUNT=' + count); + } + + // BYDAY + if (freq == Frequency.MONTHLY) { + if (byDay != null && byDay != LAST_DAY_OF_MONTH) { + ruleParts.add('BYDAY=' + byDay); + } + } else { + if (days != null && days.size() > 0) { + List dayStrings = new List(); + for (Day day : days) { + dayStrings.add(day.name()); + } + ruleParts.add('BYDAY=' + String.join(dayStrings, ',')); + } + } + + //BYSETPOS + if (bySetPos != null && freq == Frequency.MONTHLY) { + ruleParts.add('BYSETPOS=' + bySetPos); + } + + //BYMONTHDAY + if (byMonthDays != null) { + List monthDays = new List(); + for (Integer monthday : byMonthDays) { + monthDays.add(String.valueOf(monthday)); + } + + if (byDay != null && byDay == LAST_DAY_OF_MONTH) { + ruleParts.add('BYMONTHDAY=' + String.join(monthDays, ',')); + } + } + + rRule = String.join(ruleParts, ';'); + + return rRule; + } + + private void parseAndValidate(String rRuleString) { + List ruleParts = rRuleString.split(';'); + + for (String part : ruleParts) { + List thisPart = part.split('='); + + if (!thisPart.isEmpty()) { + String thisKey = thisPart[0].toUpperCase(); + String thisValue = thisPart[1]; + + switch on thisKey { + when 'FREQ' { + setFreqFromString(thisValue); + } + when 'INTERVAL' { + interval = Integer.valueOf(thisValue); + } + when 'UNTIL' { + Integer year = Integer.valueOf(thisValue.left(4)); + Integer month = Integer.valueOf(thisValue.mid(4, 2)); + Integer day = Integer.valueOf(thisValue.mid(6, 2)); + endDate = Date.newInstance(year, month, day); + } + when 'COUNT' { + count = Integer.valueOf(thisValue); + } + when 'BYDAY' { + if (thisValue.startsWith('+')) { + byDay = thisValue; + } else { + List dayStrings = thisValue.split(','); + days = new List(); + for (Day day : Day.values()) { + if (dayStrings.contains(day.name())) { + days.add(day); + } + } + } + } + when 'BYSETPOS' { + bySetPos = Integer.valueOf(thisValue); + } + when 'BYMONTHDAY' { + List monthDaysString = thisValue.split(','); + byMonthDays = new Set(); + for (String monthday : monthDaysString) { + byMonthDays.add(Integer.valueOf(monthday)); + } + } + when else { + reset(); + throw new RecurrenceRuleException('Invalid RRule Key'); + // TODO: distinguish between valid RRule parameters that we don't support, + // and invalid RRule parameters that don't exist in the spec + + // TODO: Consider validating that at least one end condition exists: count or endDate + } + } + } + } + } + + private void setFreqFromString(String freqString) { + for (Frequency frequency : Frequency.values()) { + if (frequency.name() == freqString.toUpperCase()) { + freq = frequency; + break; + } + } + if (freq == null) { + throw new RecurrenceRuleException('Invalid frequency type.'); + } + } + + private void populateDays(List dayNums) { + List daysToAssign = new List(); + List dayValues = Day.values(); + + if (dayNums == null) { + return; + } + + for (Integer dayNum : dayNums) { + if (dayNum <= dayValues.size()) { + // Expecting the day num value to start at 1; Su-Sa => 1-7 + daysToAssign.add(Day.values()[dayNum - 1]); + } else { + throw new RecurrenceRuleException('Invalid day number.'); + } + } + + days = daysToAssign; + } + + public class RecurrenceRuleException extends Exception { + } +} diff --git a/force-app/volunteers/classes/RecurrenceRule.cls-meta.xml b/force-app/volunteers/classes/RecurrenceRule.cls-meta.xml new file mode 100644 index 0000000..7d5f9e8 --- /dev/null +++ b/force-app/volunteers/classes/RecurrenceRule.cls-meta.xml @@ -0,0 +1,5 @@ + + + 61.0 + Active + \ No newline at end of file diff --git a/force-app/volunteers/classes/WeeklyIterable.cls b/force-app/volunteers/classes/WeeklyIterable.cls new file mode 100644 index 0000000..f82242c --- /dev/null +++ b/force-app/volunteers/classes/WeeklyIterable.cls @@ -0,0 +1,53 @@ +/* + * + * * Copyright (c) 2020, salesforce.com, inc. + * * All rights reserved. + * * SPDX-License-Identifier: BSD-3-Clause + * * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + * + */ + + public with sharing class WeeklyIterable extends DailyIterable { + private Integer runningDayIndex; + private List dayNums; + + public WeeklyIterable(Date runningDate, RecurrenceRule rRule) { + super(runningDate, rRule); + this.runningDayIndex = 0; + + // Default WKST=MO; + dayNums = rRule.getDayNums(); + dayNums.sort(); + if (dayNums[0] == 0) { + dayNums.remove(0); + dayNums.add(7); + } + } + + public override void calculateRunningDate() { + Integer runningDateDayNum = getDayIndex(runningDate); + Integer runningDayNum = dayNums[runningDayIndex]; + + if (runningCount == 0 && dayNums.contains(runningDateDayNum)) { + runningDayIndex = dayNums.indexOf(runningDateDayNum); + runningDate = runningDate; + } else if (runningDateDayNum < runningDayNum) { + runningDate = runningDate.addDays(runningDayNum - runningDateDayNum); + } else { + runningDate = runningDate.addDays( + (7 - runningDateDayNum + runningDayNum) + (rRule.getInterval() * 7 - 7) + ); + } + + runningDayIndex = runningDayIndex == dayNums.size() - 1 ? 0 : runningDayIndex + 1; + } + + private Integer getDayIndex(Date inputDate) { + Date sunday = Date.newInstance(2012, 1, 1); + Integer dayIndex = Math.mod(sunday.daysBetween(inputDate), 7); + + // Default WKST=MO; + dayIndex += dayIndex == 0 ? 7 : 0; + return dayIndex; + } +} diff --git a/force-app/volunteers/classes/WeeklyIterable.cls-meta.xml b/force-app/volunteers/classes/WeeklyIterable.cls-meta.xml new file mode 100644 index 0000000..7d5f9e8 --- /dev/null +++ b/force-app/volunteers/classes/WeeklyIterable.cls-meta.xml @@ -0,0 +1,5 @@ + + + 61.0 + Active + \ No newline at end of file