Skip to content

Commit

Permalink
feature(Calendar/Event): create monthly series events for 5th weekday
Browse files Browse the repository at this point in the history
  • Loading branch information
ccheng-dev committed Nov 8, 2024
1 parent f2f0535 commit 5afd45d
Show file tree
Hide file tree
Showing 6 changed files with 138 additions and 16 deletions.
17 changes: 17 additions & 0 deletions tests/tine20/Calendar/Frontend/ActiveSyncTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1565,6 +1565,23 @@ public function testUpdateEventAddRecurException()
->count(), 'expected both attendees have accepted');
}

public function testUpdate5thWeekMonthlyEvent()
{
$event = $this->_createEvent();
$event->rrule = new Calendar_Model_Rrule('FREQ=MONTHLY;INTERVAL=1;BYDAY=5TU');
$event->dtstart = new Tinebase_DateTime('2013-04-22 16:00:00');
$event->dtend = new Tinebase_DateTime('2013-04-22 17:00:00');
$event = Calendar_Controller_Event::getInstance()->update($event);

$controller = Syncroton_Data_Factory::factory($this->_class, $this->_getDevice(Syncroton_Model_Device::TYPE_IPHONE), Tinebase_DateTime::now());
$syncrotonEvent = $controller->toSyncrotonModel($event);
static::assertEquals(15, count($syncrotonEvent->exceptions), 'exception count is wrong');
static::assertEquals('2013-05-28 16:00:00', $syncrotonEvent->exceptions[0]->exceptionStartTime->get(Tinebase_Record_Abstract::ISO8601LONG), 'exception time is wrong');

$tine20Event = $controller->toTineModel($syncrotonEvent);
static::assertEquals(0, count($tine20Event->exdate), 'should not have exceptions');
}

public function testPreserveDataOnCreateRecurException()
{
$cfCfg = $this->_createCustomField(Tinebase_Record_Abstract::generateUID(), Calendar_Model_Event::class);
Expand Down
20 changes: 20 additions & 0 deletions tests/tine20/Calendar/JsonTests.php
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,26 @@ public function testUpdateRecurEventComplexRule()
$this->_uit->createRecurException($newEvent, false, true, false);
}

public function testUpdateRecurEventFifthWeekDayRule()
{
$eventData = $this->testCreateRecurEvent();
$eventData['rrule']['interval'] = 1;
$eventData['rrule']['freq'] = 'MONTHLY';
$eventData['rrule']['byday'] = '5TU';
$from = $eventData['dtstart'];
$until = new Tinebase_DateTime($from);
$until->addYear(1);
$eventData['rrule_until'] = $until;
$updatedEventData = $this->_uit->saveEvent($eventData);

$searchResultData = $this->_uit->searchEvents([
['field' => 'container_id', 'operator' => 'equals', 'value' => $this->_getTestCalendar()->getId()],
['field' => 'period', 'operator' => 'within', 'value' => ['from' => $from, 'until' => $until]],
], []);

static::assertCount(4, $searchResultData['results']);
}

/**
* testCreateRecurEventYearly
*
Expand Down
78 changes: 73 additions & 5 deletions tine20/Calendar/Frontend/ActiveSync.php
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ class Calendar_Frontend_ActiveSync extends ActiveSync_Frontend_Abstract implemen
protected $_contentController;

protected $_defaultContainerPreferenceName = Calendar_Preference::DEFAULTCALENDAR;

/**
* list of devicetypes with wrong busy status default (0 = FREE)
*
Expand Down Expand Up @@ -428,7 +428,7 @@ public function toSyncrotonModel($entry, array $options = array())

$exceptions[] = $exception;
}

$syncrotonEvent->$syncrotonProperty = $exceptions;
}

Expand Down Expand Up @@ -468,10 +468,14 @@ public function toSyncrotonModel($entry, array $options = array())
$recurrence->type = Syncroton_Model_EventRecurrence::TYPE_MONTHLY_DAYN;
$recurrence->weekOfMonth = $weekOfMonth;
$recurrence->dayOfWeek = $this->_convertDayToBitMask($dayOfWeek);

$exceptions = $this->_getExceptionsByRrule($entry);
if (count($exceptions) > 0) {
$syncrotonEvent->exceptions = $exceptions;
}
}

break;

case Calendar_Model_Rrule::FREQ_YEARLY:
if (!empty($rrule->bymonthday)) {
$recurrence->type = Syncroton_Model_EventRecurrence::TYPE_YEARLY;
Expand Down Expand Up @@ -530,6 +534,46 @@ public function toSyncrotonModel($entry, array $options = array())
return $syncrotonEvent;
}

/**
* (non-PHPdoc)
*
* compute exceptions by rrule for 2 years
*/
public function _getExceptionsByRrule($entry)
{
/** @var Calendar_Model_Event $entry */
$exceptions = [];

if (!empty($entry->rrule->byday)) {
$byDayInterval = (int) substr($entry->rrule->byday, 0, -2);
$fifthWeek = $byDayInterval === 5;

if ($fifthWeek && $entry->rrule->freq === Calendar_Model_Rrule::FREQ_MONTHLY) {
$entry->rrule->byday = -1 . substr($entry->rrule->byday, -2);
$refDate = clone($entry->dtstart);
$until = $entry->rrule_until ?? $refDate->addYear(2);
$recurSet = Calendar_Model_Rrule::computeRecurrenceSet(
$entry,
new Tinebase_Record_RecordSet('Calendar_Model_Event'),
$entry->dtstart,
$until
);

foreach ($recurSet as $record) {
if (!Calendar_Model_Rrule::isFifthWeekOfMonth($record->dtstart)) {
$exception = new Syncroton_Model_EventException();
$exception->deleted = 1;
// None. The user's response to the meeting has not yet been received.
$exception->responseType = 0;
$exception->exceptionStartTime = $record->dtstart;
$exceptions[] = $exception;
}
}
}
}
return $exceptions;
}

/**
* @param Calendar_Model_Event $_event
* @return int
Expand Down Expand Up @@ -750,7 +794,21 @@ public function toTineModel(Syncroton_Model_IEntry $data, $entry = null)
// handle exceptions from recurrence
$exdates = new Tinebase_Record_RecordSet('Calendar_Model_Event');
$oldExdates = $event->exdate instanceof Tinebase_Record_RecordSet ? $event->exdate : new Tinebase_Record_RecordSet('Calendar_Model_Event');


$fifthWeekday = isset($data->recurrence) && $data->recurrence->type === Syncroton_Model_EventRecurrence::TYPE_MONTHLY_DAYN && $data->recurrence->weekOfMonth === 5;
$recurSet = new Tinebase_Record_RecordSet(Calendar_Model_Event::class);

if ($fifthWeekday && isset($data->uID)) {
$until = clone $event->dtstart;
$event->uid = $data->uID;
$recurSet = Calendar_Model_Rrule::computeRecurrenceSet(
$event,
new Tinebase_Record_RecordSet('Calendar_Model_Event'),
$event->dtstart,
$event->rrule_until ?? $until->addYear(2)
);
}

foreach ($data->$syncrotonProperty as $exception) {
$eventException = $this->_getRecurException($oldExdates, $exception, $entry);

Expand All @@ -759,6 +817,16 @@ public function toTineModel(Syncroton_Model_IEntry $data, $entry = null)
$eventException->last_modified_time = new Tinebase_DateTime($this->_syncTimeStamp);
}

if ($fifthWeekday) {
$foundMatchedException = $recurSet->filter(function($recurrence) use ($exception) {
return $exception->exceptionStartTime->get(Tinebase_Record_Abstract::ISO8601LONG) === $recurrence->dtstart->get(Tinebase_Record_Abstract::ISO8601LONG);
});

if (count($foundMatchedException) > 0) {
continue;
}
}

$eventException->is_deleted = (bool) $exception->deleted;
$eventException->seq = isset($entry['seq']) ? $entry['seq'] : null;
$exdates->addRecord($eventException);
Expand Down
20 changes: 18 additions & 2 deletions tine20/Calendar/Model/Rrule.php
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,14 @@ public static function getRruleFromString($_rruleString)

return $rrule;
}

public static function isFifthWeekOfMonth($date): bool
{
$date = new DateTime($date);
$dayOfMonth = (int) $date->format('j');
$weekOfMonth = ceil($dayOfMonth / 7);
return $weekOfMonth === 5.0;
}

/**
* returns a ical rrule string
Expand Down Expand Up @@ -1059,7 +1067,7 @@ protected static function _computeRecurMonthlyByDay($_event, $_rrule, $_exceptio

$computationStartDateArray = self::date2array($eventInOrganizerTZ->dtstart);

// if period contains base events dtstart, we let computation start one intervall to early to catch
// if period contains base events dtstart, we let computation start one interval to early to catch
// the cases when dtstart of base event not equals the first instance. If it fits, we filter the additional
// instance out later
if ($eventInOrganizerTZ->dtstart->isLater($_from) && $eventInOrganizerTZ->dtstart->isEarlier($_until)) {
Expand All @@ -1082,6 +1090,10 @@ protected static function _computeRecurMonthlyByDay($_event, $_rrule, $_exceptio

$byDayInterval = (int) substr($_rrule->byday, 0, -2);
$byDayWeekday = substr($_rrule->byday, -2);
$fifthWeekday = $byDayInterval === 5;
if ($fifthWeekday) {
$byDayInterval = -1;
}

if ($byDayInterval === 0 || ! (isset(self::$WEEKDAY_DIGIT_MAP[$byDayWeekday]) || array_key_exists($byDayWeekday, self::$WEEKDAY_DIGIT_MAP))) {
throw new Exception('mal formated rrule byday part: "' . $_rrule->byday . '"');
Expand All @@ -1100,12 +1112,16 @@ protected static function _computeRecurMonthlyByDay($_event, $_rrule, $_exceptio
}

self::skipWday($recurEvent->dtstart, $byDayWeekday, $byDayInterval, TRUE);

// we calculate dtend from the event length, as events during a dst boundary could get dtend less than dtstart otherwise
$recurEvent->dtend = clone $recurEvent->dtstart;
$recurEvent->dtend->add($eventLength);

$recurEvent->setTimezone('UTC');

if ($fifthWeekday && !Calendar_Model_Rrule::isFifthWeekOfMonth($recurEvent->dtstart)) {
continue;
}

if ($computationEndDate->isEarlier($recurEvent->dtstart)) {
break;
Expand Down
17 changes: 9 additions & 8 deletions tine20/Calendar/js/RrulePanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ Tine.Calendar.RrulePanel = Ext.extend(Ext.Panel, {
];

this.eventEditDialog.on('dtStartChange', function(jsonData) {
var data = Ext.decode(jsonData),
const data = Ext.decode(jsonData),
dtstart = Date.parseDate(data.newValue, Date.patterns.ISO8601Long);

this.initRrule(dtstart);
Expand All @@ -142,9 +142,9 @@ Tine.Calendar.RrulePanel = Ext.extend(Ext.Panel, {

initRrule: function(dtstart) {
if (Ext.isDate(dtstart)) {
var byday = Tine.Calendar.RrulePanel.prototype.wkdays[dtstart.format('w')];
var bymonthday = dtstart.format('j');
var bymonth = dtstart.format('n');
const byday = Tine.Calendar.RrulePanel.prototype.wkdays[dtstart.format('w')];
const bymonthday = dtstart.format('j');
const bymonth = dtstart.format('n');

this.WEEKLYcard.setRule({
interval: 1,
Expand Down Expand Up @@ -172,7 +172,7 @@ Tine.Calendar.RrulePanel = Ext.extend(Ext.Panel, {
this.ruleCards.layout.layout();
this.activeRuleCard = this[freq + 'card'];
},

/**
* disable contents not panel
*/
Expand Down Expand Up @@ -529,7 +529,7 @@ Tine.Calendar.RrulePanel.AbstractCard = Ext.extend(Ext.Panel, {
this.onLimitRadioCheck(this.untilRadio, false);
this.onLimitRadioCheck(this.countRadio, true);
}
}
},
});

Tine.Calendar.RrulePanel.DAILYcard = Ext.extend(Tine.Calendar.RrulePanel.AbstractCard, {
Expand Down Expand Up @@ -668,6 +668,7 @@ Tine.Calendar.RrulePanel.MONTHLYcard = Ext.extend(Tine.Calendar.RrulePanel.Abstr
[2, this.app.i18n._('second') ],
[3, this.app.i18n._('third') ],
[4, this.app.i18n._('fourth') ],
[5, this.app.i18n._('fifth') ],
[-1, this.app.i18n._('last') ]
]
});
Expand All @@ -689,7 +690,7 @@ Tine.Calendar.RrulePanel.MONTHLYcard = Ext.extend(Tine.Calendar.RrulePanel.Abstr
value : Tine.Calendar.RrulePanel.prototype.wkdays[Ext.DatePicker.prototype.startDay],
editable : false,
mode : 'local',
store : wkdayItems
store : wkdayItems,
});

this.bymonthdayRadio = new Ext.form.Radio({
Expand Down Expand Up @@ -737,7 +738,7 @@ Tine.Calendar.RrulePanel.MONTHLYcard = Ext.extend(Tine.Calendar.RrulePanel.Abstr

Tine.Calendar.RrulePanel.MONTHLYcard.superclass.initComponent.call(this);
},

onByRadioCheck: function(radio, checked) {
switch(radio.inputValue) {
case 'BYDAY':
Expand Down
2 changes: 1 addition & 1 deletion tine20/Calendar/translations/de.po
Original file line number Diff line number Diff line change
Expand Up @@ -2391,7 +2391,7 @@ msgstr "vorletzten"

#: Model/Rrule.php:473
msgid "fifth"
msgstr "fünfte"
msgstr "fünften"

#: Model/Rrule.php:478
msgid "st"
Expand Down

0 comments on commit 5afd45d

Please sign in to comment.