public function occursAt($date)
{
$date = self::parseDate($date);
// convert timezone to dtstart timezone for comparison
$date->setTimezone($this->dtstart->getTimezone());
if (in_array($date, $this->cache)) {
// in the cache (whether cache is complete or not)
return true;
} elseif ($this->total !== null) {
// cache complete and not in cache
return false;
}
// let's start with the obvious
if ($date < $this->dtstart || $this->until && $date > $this->until) {
return false;
}
// now the BYXXX rules (expect BYSETPOS)
if ($this->byhour && !in_array($date->format('G'), $this->byhour)) {
return false;
}
if ($this->byminute && !in_array((int) $date->format('i'), $this->byminute)) {
return false;
}
if ($this->bysecond && !in_array((int) $date->format('s'), $this->bysecond)) {
return false;
}
// we need some more variables before we continue
list($year, $month, $day, $yearday, $weekday) = explode(' ', $date->format('Y n j z N'));
$masks = array();
$masks['weekday_of_1st_yearday'] = date_create($year . '-01-01 00:00:00')->format('N');
$masks['yearday_to_weekday'] = array_slice(self::$WEEKDAY_MASK, $masks['weekday_of_1st_yearday'] - 1);
if (is_leap_year($year)) {
$masks['year_len'] = 366;
$masks['last_day_of_month'] = self::$LAST_DAY_OF_MONTH_366;
} else {
$masks['year_len'] = 365;
$masks['last_day_of_month'] = self::$LAST_DAY_OF_MONTH;
}
$month_len = $masks['last_day_of_month'][$month] - $masks['last_day_of_month'][$month - 1];
if ($this->bymonth && !in_array($month, $this->bymonth)) {
return false;
}
if ($this->bymonthday || $this->bymonthday_negative) {
$monthday_negative = -1 * ($month_len - $day + 1);
if (!in_array($day, $this->bymonthday) && !in_array($monthday_negative, $this->bymonthday_negative)) {
return false;
}
}
if ($this->byyearday) {
// caution here, yearday starts from 0 !
$yearday_negative = -1 * ($masks['year_len'] - $yearday);
if (!in_array($yearday + 1, $this->byyearday) && !in_array($yearday_negative, $this->byyearday)) {
return false;
}
}
if ($this->byweekday || $this->byweekday_nth) {
// we need to summon some magic here
$this->buildNthWeekdayMask($year, $month, $day, $masks);
if (!in_array($weekday, $this->byweekday) && !isset($masks['yearday_is_nth_weekday'][$yearday])) {
return false;
}
}
if ($this->byweekno) {
// more magic
$this->buildWeeknoMask($year, $month, $day, $masks);
if (!isset($masks['yearday_is_in_weekno'][$yearday])) {
return false;
}
}
// so now we have exhausted all the BYXXX rules (exept bysetpos),
// we still need to consider frequency and interval
list($start_year, $start_month, $start_day) = explode('-', $this->dtstart->format('Y-m-d'));
switch ($this->freq) {
case self::YEARLY:
if (($year - $start_year) % $this->interval !== 0) {
return false;
}
break;
case self::MONTHLY:
// we need to count the number of months elapsed
$diff = 12 - $start_month + 12 * ($year - $start_year - 1) + $month;
if ($diff % $this->interval !== 0) {
return false;
}
break;
case self::WEEKLY:
// count nb of days and divide by 7 to get number of weeks
// we add some days to align dtstart with wkst
$diff = $date->diff($this->dtstart);
$diff = (int) (($diff->days + pymod($this->dtstart->format('N') - $this->wkst, 7)) / 7);
if ($diff % $this->interval !== 0) {
return false;
}
break;
case self::DAILY:
// count nb of days
$diff = $date->diff($this->dtstart);
if ($diff->days % $this->interval !== 0) {
return false;
}
break;
// XXX: I'm not sure the 3 formulas below take the DST into account...
// XXX: I'm not sure the 3 formulas below take the DST into account...
case self::HOURLY:
$diff = $date->diff($this->dtstart);
$diff = $diff->h + $diff->days * 24;
if ($diff % $this->interval !== 0) {
return false;
}
break;
case self::MINUTELY:
$diff = $date->diff($this->dtstart);
$diff = $diff->i + $diff->h * 60 + $diff->days * 1440;
if ($diff % $this->interval !== 0) {
return false;
}
break;
case self::SECONDLY:
$diff = $date->diff($this->dtstart);
// XXX does not account for leap second (should it?)
$diff = $diff->s + $diff->i * 60 + $diff->h * 3600 + $diff->days * 86400;
if ($diff % $this->interval !== 0) {
return false;
}
break;
throw new \Exception('Unimplemented frequency');
}
// now we are left with 2 rules BYSETPOS and COUNT
//
// - I think BYSETPOS *could* be determined without loooping by considering
// the current set, calculating all the occurrences of the current set
// and determining the position of $date in the result set.
// However I'm not convinced it's worth it.
//
// - I don't see any way to determine COUNT programmatically, because occurrences
// might sometimes be dropped (e.g. a 29 Feb on a normal year, or during
// the switch to DST) and not counted in the final set
if (!$this->count && !$this->bysetpos) {
return true;
}
// so... as a fallback we have to loop
foreach ($this as $occurrence) {
if ($occurrence == $date) {
return true;
// lucky you!
}
if ($occurrence > $date) {
break;
}
}
// we ended the loop without finding
return false;
}
public function testOccursAtTakeTimezoneIntoAccount() { $rrule = new RRule(array('freq' => 'daily', 'count' => 365, 'dtstart' => date_create('2015-07-01 09:00:00'))); $this->assertTrue($rrule->occursAt('2015-07-02 09:00:00'), 'When timezone is not specified, it takes the default timezone'); $rrule = new RRule(array('freq' => 'daily', 'count' => 365, 'dtstart' => date_create('2015-07-01 09:00:00', new DateTimeZone('Australia/Sydney')))); $this->assertTrue($rrule->occursAt(date_create('2015-07-02 09:00:00', new DateTimeZone('Australia/Sydney')))); $this->assertTrue($rrule->occursAt(date_create('2015-07-01 23:00:00', new DateTimeZone('UTC'))), 'Timezone is converted for comparison (cached)'); $rrule->clearCache(); $this->assertTrue($rrule->occursAt(date_create('2015-07-01 23:00:00', new DateTimeZone('UTC'))), 'Timezone is converted for comparison (uncached)'); $rrule->clearCache(); $this->assertFalse($rrule->occursAt('2015-07-02 09:00:00'), 'When passed a string, default timezone is used for creating the DateTime'); $rrule->clearCache(); $this->assertTrue($rrule->occursAt('Wed, 01 Jul 2015 09:00:00 +1000'), 'When passed a string with timezone, timezone is kept (uncached)'); $this->assertTrue($rrule->occursAt('Wed, 01 Jul 2015 09:00:00 +1000'), 'When passed a string with timezone, timezone is kept (cached)'); $rrule->clearCache(); $this->assertTrue($rrule->occursAt('2015-07-01T09:00:00+10:00'), 'When passed a string with timezone, timezone is kept (uncached)'); $this->assertTrue($rrule->occursAt('2015-07-01T09:00:00+10:00'), 'When passed a string with timezone, timezone is kept (cached)'); // test with DST $rrule = new RRule(array('freq' => 'daily', 'count' => 365, 'dtstart' => date_create('2015-07-01 09:00:00', new DateTimeZone('Europe/Helsinki')))); $this->assertTrue($rrule->occursAt(date_create('2015-07-02 09:00:00', new DateTimeZone('Europe/Helsinki')))); $this->assertTrue($rrule->occursAt(date_create('2015-07-02 06:00:00', new DateTimeZone('UTC'))), 'During summer time, Europe/Helsinki is UTC+3 (cached)'); $rrule->clearCache(); $this->assertTrue($rrule->occursAt(date_create('2015-07-02 06:00:00', new DateTimeZone('UTC'))), 'During summer time, Europe/Helsinki is UTC+3 (uncached)'); $this->assertTrue($rrule->occursAt(date_create('2015-12-02 09:00:00', new DateTimeZone('Europe/Helsinki')))); $this->assertTrue($rrule->occursAt(date_create('2015-12-02 07:00:00', new DateTimeZone('UTC'))), 'During winter time, Europe/Helsinki is UTC+2 (cached)'); $rrule->clearCache(); $this->assertTrue($rrule->occursAt(date_create('2015-12-02 07:00:00', new DateTimeZone('UTC'))), 'During winter time, Europe/Helsinki is UTC+2 (uncached)'); }