diff --git a/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php b/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php
index 7a18ddc..c8d2e08 100644
--- a/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php
+++ b/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php
@@ -1,1397 +1,1549 @@
 <?php
 
 final class PhutilCalendarRecurrenceRule
   extends PhutilCalendarRecurrenceSource {
 
   private $startDateTime;
   private $frequency;
   private $frequencyScale;
   private $interval = 1;
   private $bySecond = array();
   private $byMinute = array();
   private $byHour = array();
   private $byDay = array();
   private $byMonthDay = array();
   private $byYearDay = array();
   private $byWeekNumber = array();
   private $byMonth = array();
   private $bySetPosition = array();
   private $weekStart = self::WEEKDAY_MONDAY;
 
   private $cursorSecond;
   private $cursorMinute;
   private $cursorHour;
   private $cursorHourState;
   private $cursorWeek;
+  private $cursorWeekday;
+  private $cursorWeekState;
   private $cursorDay;
   private $cursorDayState;
   private $cursorMonth;
   private $cursorYear;
 
   private $setSeconds;
   private $setMinutes;
   private $setHours;
   private $setDays;
   private $setMonths;
+  private $setWeeks;
   private $setYears;
 
   private $stateSecond;
   private $stateMinute;
   private $stateHour;
   private $stateDay;
+  private $stateWeek;
   private $stateMonth;
   private $stateYear;
 
   private $baseYear;
   private $isAllDay;
   private $activeSet = array();
   private $nextSet = array();
   private $minimumEpoch;
 
   const FREQUENCY_SECONDLY = 'SECONDLY';
   const FREQUENCY_MINUTELY = 'MINUTELY';
   const FREQUENCY_HOURLY = 'HOURLY';
   const FREQUENCY_DAILY = 'DAILY';
   const FREQUENCY_WEEKLY = 'WEEKLY';
   const FREQUENCY_MONTHLY = 'MONTHLY';
   const FREQUENCY_YEARLY = 'YEARLY';
 
   const SCALE_SECONDLY = 1;
   const SCALE_MINUTELY = 2;
   const SCALE_HOURLY = 3;
   const SCALE_DAILY = 4;
   const SCALE_WEEKLY = 5;
   const SCALE_MONTHLY = 6;
   const SCALE_YEARLY = 7;
 
   const WEEKDAY_SUNDAY = 'SU';
   const WEEKDAY_MONDAY = 'MO';
   const WEEKDAY_TUESDAY = 'TU';
   const WEEKDAY_WEDNESDAY = 'WE';
   const WEEKDAY_THURSDAY = 'TH';
   const WEEKDAY_FRIDAY = 'FR';
   const WEEKDAY_SATURDAY = 'SA';
 
   const WEEKINDEX_SUNDAY = 0;
   const WEEKINDEX_MONDAY = 1;
   const WEEKINDEX_TUESDAY = 2;
   const WEEKINDEX_WEDNESDAY = 3;
   const WEEKINDEX_THURSDAY = 4;
   const WEEKINDEX_FRIDAY = 5;
   const WEEKINDEX_SATURDAY = 6;
 
   private static function getAllWeekdayConstants() {
     return array_keys(self::getWeekdayIndexMap());
   }
 
   private static function getWeekdayIndexMap() {
     static $map = array(
       self::WEEKDAY_SUNDAY => self::WEEKINDEX_SUNDAY,
       self::WEEKDAY_MONDAY => self::WEEKINDEX_MONDAY,
       self::WEEKDAY_TUESDAY => self::WEEKINDEX_TUESDAY,
       self::WEEKDAY_WEDNESDAY => self::WEEKINDEX_WEDNESDAY,
       self::WEEKDAY_THURSDAY => self::WEEKINDEX_THURSDAY,
       self::WEEKDAY_FRIDAY => self::WEEKINDEX_FRIDAY,
       self::WEEKDAY_SATURDAY => self::WEEKINDEX_SATURDAY,
     );
 
     return $map;
   }
 
   private static function getWeekdayIndex($weekday) {
     $map = self::getWeekdayIndexMap();
     if (!isset($map[$weekday])) {
       $constants = array_keys($map);
       throw new Exception(
         pht(
           'Weekday "%s" is not a valid weekday constant. Valid constants '.
           'are: %s.',
           $weekday,
           implode(', ', $constants)));
     }
 
     return $map[$weekday];
   }
 
   public function setStartDateTime(PhutilCalendarDateTime $start) {
     $this->startDateTime = $start;
     return $this;
   }
 
   public function getStartDateTime() {
     return $this->startDateTime;
   }
 
   public function setFrequency($frequency) {
     static $map = array(
       self::FREQUENCY_SECONDLY => self::SCALE_SECONDLY,
       self::FREQUENCY_MINUTELY => self::SCALE_MINUTELY,
       self::FREQUENCY_HOURLY => self::SCALE_HOURLY,
       self::FREQUENCY_DAILY => self::SCALE_DAILY,
       self::FREQUENCY_WEEKLY => self::SCALE_WEEKLY,
       self::FREQUENCY_MONTHLY => self::SCALE_MONTHLY,
       self::FREQUENCY_YEARLY => self::SCALE_YEARLY,
     );
 
     if (empty($map[$frequency])) {
       throw new Exception(
         pht(
           'RRULE FREQ "%s" is invalid. Valid frequencies are: %s.',
           $frequency,
           implode(', ', array_keys($map))));
     }
 
     $this->frequency = $frequency;
     $this->frequencyScale = $map[$frequency];
 
     return $this;
   }
 
   public function getFrequency() {
     return $this->frequency;
   }
 
   public function getFrequencyScale() {
     return $this->frequencyScale;
   }
 
   public function setInterval($interval) {
     if (!is_int($interval)) {
       throw new Exception(
         pht(
           'RRULE INTERVAL "%s" is invalid: interval must be an integer.',
           $interval));
     }
 
     if ($interval < 1) {
       throw new Exception(
         pht(
           'RRULE INTERVAL "%s" is invalid: interval must be 1 or more.',
           $interval));
     }
 
     $this->interval = $interval;
     return $this;
   }
 
   public function getInterval() {
     return $this->interval;
   }
 
   public function setBySecond(array $by_second) {
     $this->assertByRange('BYSECOND', $by_second, 0, 60);
     $this->bySecond = array_fuse($by_second);
     return $this;
   }
 
   public function getBySecond() {
     return $this->bySecond;
   }
 
   public function setByMinute(array $by_minute) {
     $this->assertByRange('BYMINUTE', $by_minute, 0, 59);
     $this->byMinute = array_fuse($by_minute);
     return $this;
   }
 
   public function getByMinute() {
     return $this->byMinute;
   }
 
   public function setByHour(array $by_hour) {
     $this->assertByRange('BYHOUR', $by_hour, 0, 23);
     $this->byHour = array_fuse($by_hour);
     return $this;
   }
 
   public function getByHour() {
     return $this->byHour;
   }
 
   public function setByDay(array $by_day) {
     $constants = self::getAllWeekdayConstants();
     $constants = implode('|', $constants);
 
     $pattern = '/^(?:[+-]?([1-9]\d?))?('.$constants.')\z/';
     foreach ($by_day as $key => $value) {
       $matches = null;
       if (!preg_match($pattern, $value, $matches)) {
         throw new Exception(
           pht(
             'RRULE BYDAY value "%s" is invalid: rule part must be in the '.
             'expected form (like "MO", "-3TH", or "+2SU").',
             $value));
       }
 
       // The maximum allowed value is 53, which corresponds to "the 53rd
       // Monday every year" or similar when evaluated against a YEARLY rule.
 
       $maximum = 53;
       $magnitude = (int)$matches[1];
       if ($magnitude > $maximum) {
         throw new Exception(
           pht(
             'RRULE BYDAY value "%s" has an offset with magnitude "%s", but '.
             'the maximum permitted value is "%s".',
             $value,
             $magnitude,
             $maximum));
       }
 
       // Normalize "+3FR" into "3FR".
       $by_day[$key] = ltrim($value, '+');
     }
 
     $this->byDay = array_fuse($by_day);
     return $this;
   }
 
   public function getByDay() {
     return $this->byDay;
   }
 
   public function setByMonthDay(array $by_month_day) {
     $this->assertByRange('BYMONTHDAY', $by_month_day, -31, 31, false);
     $this->byMonthDay = array_fuse($by_month_day);
     return $this;
   }
 
   public function getByMonthDay() {
     return $this->byMonthDay;
   }
 
   public function setByYearDay($by_year_day) {
     $this->assertByRange('BYYEARDAY', $by_year_day, -366, 366, false);
     $this->byYearDay = array_fuse($by_year_day);
     return $this;
   }
 
   public function getByYearDay() {
     return $this->byYearDay;
   }
 
   public function setByMonth(array $by_month) {
     $this->assertByRange('BYMONTH', $by_month, 1, 12);
     $this->byMonth = array_fuse($by_month);
     return $this;
   }
 
   public function getByMonth() {
     return $this->byMonth;
   }
 
   public function setByWeekNumber(array $by_week_number) {
     $this->assertByRange('BYWEEKNO', $by_week_number, -53, 53, false);
     $this->byWeekNumber = array_fuse($by_week_number);
     return $this;
   }
 
   public function getByWeekNumber() {
     return $this->byWeekNumber;
   }
 
   public function setBySetPosition(array $by_set_position) {
     $this->assertByRange('BYSETPOS', $by_set_position, -366, 366, false);
     $this->bySetPosition = $by_set_position;
     return $this;
   }
 
   public function getBySetPosition() {
     return $this->bySetPosition;
   }
 
   public function setWeekStart($week_start) {
     // Make sure this is a valid weekday constant.
     self::getWeekdayIndex($week_start);
 
     $this->weekStart = $week_start;
     return $this;
   }
 
   public function getWeekStart() {
     return $this->weekStart;
   }
 
   public function resetSource() {
+    $frequency = $this->getFrequency();
+
+    if ($this->getByMonthDay()) {
+      switch ($frequency) {
+        case self::FREQUENCY_WEEKLY:
+          // RFC5545: "The BYMONTHDAY rule part MUST NOT be specified when the
+          // FREQ rule part is set to WEEKLY."
+          throw new Exception(
+            pht(
+              'RRULE specifies BYMONTHDAY with FREQ set to WEEKLY, which '.
+              'violates RFC5545.'));
+          break;
+        default:
+          break;
+      }
+
+    }
+
+    if ($this->getByYearDay()) {
+      switch ($frequency) {
+        case self::FREQUENCY_DAILY:
+        case self::FREQUENCY_WEEKLY:
+        case self::FREQUENCY_MONTHLY:
+          // RFC5545: "The BYYEARDAY rule part MUST NOT be specified when the
+          // FREQ rule part is set to DAILY, WEEKLY, or MONTHLY."
+          throw new Exception(
+            pht(
+              'RRULE specifies BYYEARDAY with FREQ of DAILY, WEEKLY or '.
+              'MONTHLY, which violates RFC5545.'));
+        default:
+          break;
+      }
+    }
+
+    // TODO
+    // RFC5545: "The BYDAY rule part MUST NOT be specified with a numeric
+    // value when the FREQ rule part is not set to MONTHLY or YEARLY."
+    // RFC5545: "Furthermore, the BYDAY rule part MUST NOT be specified with a
+    // numeric value with the FREQ rule part set to YEARLY when the BYWEEKNO
+    // rule part is specified."
+
+
     $date = $this->getStartDateTime();
 
     $this->cursorSecond = $date->getSecond();
     $this->cursorMinute = $date->getMinute();
     $this->cursorHour = $date->getHour();
 
-    // TODO: Figure this out.
-    $this->cursorWeek = null;
     $this->cursorDay = $date->getDay();
     $this->cursorMonth = $date->getMonth();
     $this->cursorYear = $date->getYear();
 
+    $year_map = $this->getYearMap($this->cursorYear, $this->getWeekStart());
+    $key = $this->cursorMonth.'M'.$this->cursorDay.'D';
+    $this->cursorWeek = $year_map['info'][$key]['week'];
+    $this->cursorWeekday = $year_map['info'][$key]['weekday'];
+
     $this->setSeconds = array();
     $this->setMinutes = array();
     $this->setHours = array();
     $this->setDays = array();
     $this->setMonths = array();
     $this->setYears = array();
 
     $this->stateSecond = null;
     $this->stateMinute = null;
     $this->stateHour = null;
     $this->stateDay = null;
+    $this->stateWeek = null;
     $this->stateMonth = null;
     $this->stateYear = null;
 
     // If we have a BYSETPOS, we need to generate the entire set before we
     // can filter it and return results. Normally, we start generating at
     // the start date, but we need to go back one interval to generate
     // BYSETPOS events so we can make sure the entire set is generated.
     if ($this->getBySetPosition()) {
-      $frequency = $this->getFrequency();
       $interval = $this->getInterval();
       switch ($frequency) {
         case self::FREQUENCY_YEARLY:
           $this->cursorYear -= $interval;
           break;
         case self::FREQUENCY_MONTHLY:
           $this->cursorMonth -= $interval;
           $this->rewindMonth();
           break;
         case self::FREQUENCY_DAILY:
           $this->cursorDay -= $interval;
           $this->rewindDay();
           break;
         case self::FREQUENCY_HOURLY:
           $this->cursorHour -= $interval;
           $this->rewindHour();
           break;
         case self::FREQUENCY_MINUTELY:
           $this->cursorMinute -= $interval;
           $this->rewindMinute();
           break;
         case self::FREQUENCY_SECONDLY:
         default:
           throw new Exception(
             pht(
               'RRULE specifies BYSETPOS with FREQ "%s", but this is invalid.',
               $frequency));
       }
-
-      $this->minimumEpoch = $this->getStartDateTime()->getEpoch();
-    } else {
-      $this->minimumEpoch = null;
     }
 
+    // We can generate events from before the cursor when evaluating rules
+    // with BYSETPOS or FREQ=WEEKLY.
+    $this->minimumEpoch = $this->getStartDateTime()->getEpoch();
+
     $cursor_state = array(
       'year' => $this->cursorYear,
       'month' => $this->cursorMonth,
+      'week' => $this->cursorWeek,
       'day' => $this->cursorDay,
       'hour' => $this->cursorHour,
     );
 
     $this->cursorDayState = $cursor_state;
+    $this->cursorWeekState = $cursor_state;
     $this->cursorHourState = $cursor_state;
 
     $by_hour = $this->getByHour();
     $by_minute = $this->getByMinute();
     $by_second = $this->getBySecond();
 
     $scale = $this->getFrequencyScale();
 
     // We return all-day events if the start date is an all-day event and we
     // don't have more granular selectors or a more granular frequency.
     $this->isAllDay = $date->getIsAllDay()
       && !$by_hour
       && !$by_minute
       && !$by_second
       && ($scale > self::SCALE_HOURLY);
   }
 
   public function getNextEvent($cursor) {
     while (true) {
       $event = $this->generateNextEvent();
       if (!$event) {
         break;
       }
 
       $epoch = $event->getEpoch();
       if ($this->minimumEpoch) {
         if ($epoch < $this->minimumEpoch) {
           continue;
         }
       }
 
       if ($epoch < $cursor) {
         continue;
       }
 
       break;
     }
 
     return $event;
   }
 
   private function generateNextEvent() {
     if ($this->activeSet) {
       return array_pop($this->activeSet);
     }
 
     $this->baseYear = $this->cursorYear;
 
     $by_setpos = $this->getBySetPosition();
     if ($by_setpos) {
       $old_state = $this->getSetPositionState();
     }
 
     while (!$this->activeSet) {
       $this->activeSet = $this->nextSet;
       $this->nextSet = array();
 
       while (true) {
         if ($this->isAllDay) {
           $this->nextDay();
         } else {
           $this->nextSecond();
         }
 
         $result = id(new PhutilCalendarAbsoluteDateTime())
           ->setViewerTimezone($this->getViewerTimezone())
           ->setYear($this->stateYear)
           ->setMonth($this->stateMonth)
           ->setDay($this->stateDay);
 
         if ($this->isAllDay) {
           $result->setIsAllDay(true);
         } else {
           $result
             ->setHour($this->stateHour)
             ->setMinute($this->stateMinute)
             ->setSecond($this->stateSecond);
         }
 
         // If we don't have BYSETPOS, we're all done. We put this into the
         // set and will immediately return it.
         if (!$by_setpos) {
           $this->activeSet[] = $result;
           break;
         }
 
         // Otherwise, check if we've completed a set. The set is complete if
         // the state has moved past the span we were examining (for example,
         // with a YEARLY event, if the state is now in the next year).
         $new_state = $this->getSetPositionState();
         if ($new_state == $old_state) {
           $this->activeSet[] = $result;
           continue;
         }
 
         $this->activeSet = $this->applySetPos($this->activeSet, $by_setpos);
         $this->activeSet = array_reverse($this->activeSet);
         $this->nextSet[] = $result;
         $old_state = $new_state;
         break;
       }
     }
 
     return array_pop($this->activeSet);
   }
 
 
   protected function nextSecond() {
     if ($this->setSeconds) {
       $this->stateSecond = array_pop($this->setSeconds);
       return;
     }
 
     $frequency = $this->getFrequency();
     $interval = $this->getInterval();
     $is_secondly = ($frequency == self::FREQUENCY_SECONDLY);
     $by_second = $this->getBySecond();
 
     while (!$this->setSeconds) {
       $this->nextMinute();
 
       if ($is_secondly || $by_second) {
         $seconds = $this->newSecondsSet(
           ($is_secondly ? $interval : 1),
           $by_second);
       } else {
         $seconds = array(
           $this->cursorSecond,
         );
       }
 
       $this->setSeconds = array_reverse($seconds);
     }
 
     $this->stateSecond = array_pop($this->setSeconds);
   }
 
   protected function nextMinute() {
     if ($this->setMinutes) {
       $this->stateMinute = array_pop($this->setMinutes);
       return;
     }
 
     $frequency = $this->getFrequency();
     $interval = $this->getInterval();
     $scale = $this->getFrequencyScale();
     $is_minutely = ($frequency === self::FREQUENCY_MINUTELY);
     $by_minute = $this->getByMinute();
 
     while (!$this->setMinutes) {
       $this->nextHour();
 
       if ($is_minutely || $by_minute) {
         $minutes = $this->newMinutesSet(
           ($is_minutely ? $interval : 1),
           $by_minute);
       } else if ($scale < self::SCALE_MINUTELY) {
         $minutes = $this->newMinutesSet(
           1,
           array());
       } else {
         $minutes = array(
           $this->cursorMinute,
         );
       }
 
       $this->setMinutes = array_reverse($minutes);
     }
 
     $this->stateMinute = array_pop($this->setMinutes);
   }
 
   protected function nextHour() {
     if ($this->setHours) {
       $this->stateHour = array_pop($this->setHours);
       return;
     }
 
     $frequency = $this->getFrequency();
     $interval = $this->getInterval();
     $scale = $this->getFrequencyScale();
     $is_hourly = ($frequency === self::FREQUENCY_HOURLY);
     $by_hour = $this->getByHour();
 
     while (!$this->setHours) {
       $this->nextDay();
 
       $is_dynamic = $is_hourly
         || $by_hour
         || ($scale < self::SCALE_HOURLY);
 
       if ($is_dynamic) {
         $hours = $this->newHoursSet(
           ($is_hourly ? $interval : 1),
           $by_hour);
       } else {
         $hours = array(
           $this->cursorHour,
         );
       }
 
       $this->setHours = array_reverse($hours);
     }
 
     $this->stateHour = array_pop($this->setHours);
   }
 
   protected function nextDay() {
     if ($this->setDays) {
-      $this->stateDay = array_pop($this->setDays);
+      $info = array_pop($this->setDays);
+      $this->setDayState($info);
       return;
     }
 
     $frequency = $this->getFrequency();
     $interval = $this->getInterval();
     $scale = $this->getFrequencyScale();
     $is_daily = ($frequency === self::FREQUENCY_DAILY);
     $is_weekly = ($frequency === self::FREQUENCY_WEEKLY);
 
     $by_day = $this->getByDay();
     $by_monthday = $this->getByMonthDay();
     $by_yearday = $this->getByYearDay();
     $by_weekno = $this->getByWeekNumber();
     $by_month = $this->getByMonth();
     $week_start = $this->getWeekStart();
 
     while (!$this->setDays) {
-      $this->nextMonth();
+      if ($is_weekly) {
+        $this->nextWeek();
+      } else {
+        $this->nextMonth();
+      }
 
       $is_dynamic = $is_daily
+        || $is_weekly
         || $by_day
         || $by_monthday
         || $by_yearday
         || $by_weekno
         || ($scale < self::SCALE_DAILY);
 
       if ($is_dynamic) {
         $weeks = $this->newDaysSet(
           ($is_daily ? $interval : 1),
-          ($is_weekly ? $interval : null),
+          ($is_weekly ? $interval : 1),
           $by_day,
           $by_monthday,
           $by_yearday,
           $by_weekno,
           $week_start);
       } else {
         // The cursor day may not actually exist in the current month, so
         // make sure the day is valid before we generate a set which contains
         // it.
         $year_map = $this->getYearMap($this->stateYear, $week_start);
         if ($this->cursorDay > $year_map['monthDays'][$this->stateMonth]) {
           $weeks = array(
             array(),
           );
         } else {
+          $key = $this->stateMonth.'M'.$this->cursorDay.'D';
           $weeks = array(
-            array($this->cursorDay),
+            array($year_map['info'][$key]),
           );
         }
       }
 
       // Unpack the weeks into days.
       $days = array_mergev($weeks);
 
       $this->setDays = array_reverse($days);
     }
 
-    $this->stateDay = array_pop($this->setDays);
+    $info = array_pop($this->setDays);
+    $this->setDayState($info);
+  }
+
+  private function setDayState(array $info) {
+    $this->stateDay = $info['monthday'];
+    $this->stateWeek = $info['week'];
+    $this->stateMonth = $info['month'];
   }
 
   protected function nextMonth() {
     if ($this->setMonths) {
       $this->stateMonth = array_pop($this->setMonths);
       return;
     }
 
     $frequency = $this->getFrequency();
     $interval = $this->getInterval();
     $scale = $this->getFrequencyScale();
     $is_monthly = ($frequency === self::FREQUENCY_MONTHLY);
 
     $by_month = $this->getByMonth();
 
     // If we have a BYMONTHDAY, we consider that set of days in every month.
     // For example, "FREQ=YEARLY;BYMONTHDAY=3" means "the third day of every
     // month", so we need to expand the month set if the constraint is present.
     $by_monthday = $this->getByMonthDay();
 
     // Likewise, we need to generate all months if we have BYYEARDAY or
     // BYWEEKNO or BYDAY.
     $by_yearday = $this->getByYearDay();
     $by_weekno = $this->getByWeekNumber();
     $by_day = $this->getByDay();
 
     while (!$this->setMonths) {
       $this->nextYear();
 
       $is_dynamic = $is_monthly
         || $by_month
         || $by_monthday
         || $by_yearday
         || $by_weekno
         || $by_day
         || ($scale < self::SCALE_MONTHLY);
 
       if ($is_dynamic) {
         $months = $this->newMonthsSet(
           ($is_monthly ? $interval : 1),
           $by_month);
       } else {
         $months = array(
           $this->cursorMonth,
         );
       }
 
       $this->setMonths = array_reverse($months);
     }
 
     $this->stateMonth = array_pop($this->setMonths);
   }
 
+  protected function nextWeek() {
+    if ($this->setWeeks) {
+      $this->stateWeek = array_pop($this->setWeeks);
+      return;
+    }
+
+    $frequency = $this->getFrequency();
+    $interval = $this->getInterval();
+    $scale = $this->getFrequencyScale();
+    $by_weekno = $this->getByWeekNumber();
+
+    while (!$this->setWeeks) {
+      $this->nextYear();
+
+      $weeks = $this->newWeeksSet(
+        $interval,
+        $by_weekno);
+
+      $this->setWeeks = array_reverse($weeks);
+    }
+
+    $this->stateWeek = array_pop($this->setWeeks);
+  }
+
   protected function nextYear() {
     $this->stateYear = $this->cursorYear;
 
     $frequency = $this->getFrequency();
     $is_yearly = ($frequency === self::FREQUENCY_YEARLY);
 
     if ($is_yearly) {
       $interval = $this->getInterval();
     } else {
       $interval = 1;
     }
 
     $this->cursorYear = $this->cursorYear + $interval;
 
     if ($this->cursorYear > ($this->baseYear + 100)) {
       throw new Exception(
         pht(
           'RRULE evaluation failed to generate more events in the next 100 '.
           'years. This RRULE is likely invalid or degenerate.'));
     }
 
   }
 
   private function newSecondsSet($interval, $set) {
     // TODO: This doesn't account for leap seconds. In theory, it probably
     // should, although this shouldn't impact any real events.
     $seconds_in_minute = 60;
 
     if ($this->cursorSecond >= $seconds_in_minute) {
       $this->cursorSecond -= $seconds_in_minute;
       return array();
     }
 
     list($cursor, $result) = $this->newIteratorSet(
       $this->cursorSecond,
       $interval,
       $set,
       $seconds_in_minute);
 
     $this->cursorSecond = ($cursor - $seconds_in_minute);
 
     return $result;
   }
 
   private function newMinutesSet($interval, $set) {
     // NOTE: This value is legitimately a constant! Amazing!
     $minutes_in_hour = 60;
 
     if ($this->cursorMinute >= $minutes_in_hour) {
       $this->cursorMinute -= $minutes_in_hour;
       return array();
     }
 
     list($cursor, $result) = $this->newIteratorSet(
       $this->cursorMinute,
       $interval,
       $set,
       $minutes_in_hour);
 
     $this->cursorMinute = ($cursor - $minutes_in_hour);
 
     return $result;
   }
 
   private function newHoursSet($interval, $set) {
     // TODO: This doesn't account for hours caused by daylight savings time.
     // It probably should, although this seems unlikely to impact any real
     // events.
     $hours_in_day = 24;
 
     // If the hour cursor is behind the current time, we need to forward it in
     // INTERVAL increments so we end up with the right offset.
     list($skip, $this->cursorHourState) = $this->advanceCursorState(
       $this->cursorHourState,
       self::SCALE_HOURLY,
       $interval,
       $this->getWeekStart());
 
     if ($skip) {
       return array();
     }
 
     list($cursor, $result) = $this->newIteratorSet(
       $this->cursorHour,
       $interval,
       $set,
       $hours_in_day);
 
     $this->cursorHour = ($cursor - $hours_in_day);
 
     return $result;
   }
 
+  private function newWeeksSet($interval, $set) {
+    $week_start = $this->getWeekStart();
+
+    list($skip, $this->cursorWeekState) = $this->advanceCursorState(
+      $this->cursorWeekState,
+      self::SCALE_WEEKLY,
+      $interval,
+      $week_start);
+
+    if ($skip) {
+      return array();
+    }
+
+    $year_map = $this->getYearMap($this->stateYear, $week_start);
+
+    $result = array();
+    while (true) {
+      if (!isset($year_map['weekMap'][$this->cursorWeek])) {
+        break;
+      }
+      $result[] = $this->cursorWeek;
+      $this->cursorWeek += $interval;
+    }
+
+    $this->cursorWeek -= $year_map['weekCount'];
+
+    return $result;
+  }
+
   private function newDaysSet(
     $interval_day,
     $interval_week,
     $by_day,
     $by_monthday,
     $by_yearday,
     $by_weekno,
     $week_start) {
 
+    $frequency = $this->getFrequency();
+    $is_yearly = ($frequency == self::FREQUENCY_YEARLY);
+    $is_monthly = ($frequency == self::FREQUENCY_MONTHLY);
+    $is_weekly = ($frequency == self::FREQUENCY_WEEKLY);
+
     $selection = array();
-    if ($interval_week) {
+    if ($is_weekly) {
       $year_map = $this->getYearMap($this->stateYear, $week_start);
 
-      while (true) {
-        // TODO: This is all garbage?
-        if ($this->cursorWeek > $year_map['weekCount']) {
-          $this->cursorWeek -= $year_map['weekCount'];
-          break;
-        }
-
-        foreach ($year_map['weeks'][$this->cursorWeek] as $key) {
+      if (isset($year_map['weekMap'][$this->stateWeek])) {
+        foreach ($year_map['weekMap'][$this->stateWeek] as $key) {
           $selection[] = $year_map['info'][$key];
         }
-
-        $last = last($selection);
-        if ($last['month'] > $this->stateMonth) {
-          break;
-        }
-
-        $this->cursorWeek += $interval_week;
       }
     } else {
-      if (!$interval_day) {
-        $interval_day = 1;
-      }
-
       // If the day cursor is behind the current year and month, we need to
       // forward it in INTERVAL increments so we end up with the right offset
       // in the current month.
       list($skip, $this->cursorDayState) = $this->advanceCursorState(
         $this->cursorDayState,
         self::SCALE_DAILY,
         $interval_day,
         $week_start);
 
       if (!$skip) {
         $year_map = $this->getYearMap($this->stateYear, $week_start);
         while (true) {
           $month_idx = $this->stateMonth;
           $month_days = $year_map['monthDays'][$month_idx];
           if ($this->cursorDay > $month_days) {
             // NOTE: The year map is now out of date, but we're about to break
             // out of the loop anyway so it doesn't matter.
             break;
           }
 
           $day_idx = $this->cursorDay;
 
           $key = "{$month_idx}M{$day_idx}D";
           $selection[] = $year_map['info'][$key];
 
           $this->cursorDay += $interval_day;
         }
       }
     }
 
-    $frequency = $this->getFrequency();
-    $is_yearly = ($frequency == self::FREQUENCY_YEARLY);
-    $is_monthly = ($frequency == self::FREQUENCY_MONTHLY);
-
     // As a special case, BYDAY applies to relative month offsets if BYMONTH
     // is present in a YEARLY rule.
     if ($is_yearly) {
       if ($this->getByMonth()) {
         $is_yearly = false;
         $is_monthly = true;
       }
     }
 
+    // As a special case, BYDAY makes us examine all week days. This doesn't
+    // check BYMONTHDAY or BYYEARDAY because they are not valid with WEEKLY.
+    $filter_weekday = true;
+    if ($is_weekly) {
+      if ($by_day) {
+        $filter_weekday = false;
+      }
+    }
+
     $weeks = array();
     foreach ($selection as $key => $info) {
-      if ($info['month'] != $this->stateMonth) {
-        continue;
+      if ($is_weekly) {
+        if ($filter_weekday) {
+          if ($info['weekday'] != $this->cursorWeekday) {
+            continue;
+          }
+        }
+      } else {
+        if ($info['month'] != $this->stateMonth) {
+          continue;
+        }
       }
 
       if ($by_day) {
         if (empty($by_day[$info['weekday']])) {
           if ($is_yearly) {
             if (empty($by_day[$info['weekday.yearly']]) &&
                 empty($by_day[$info['-weekday.yearly']])) {
               continue;
             }
           } else if ($is_monthly) {
             if (empty($by_day[$info['weekday.monthly']]) &&
                 empty($by_day[$info['-weekday.monthly']])) {
               continue;
             }
           } else {
             continue;
           }
         }
       }
 
       if ($by_monthday) {
         if (empty($by_monthday[$info['monthday']]) &&
             empty($by_monthday[$info['-monthday']])) {
           continue;
         }
       }
 
       if ($by_yearday) {
         if (empty($by_yearday[$info['yearday']]) &&
             empty($by_yearday[$info['-yearday']])) {
           continue;
         }
       }
 
       if ($by_weekno) {
         if (empty($by_weekno[$info['week']]) &&
             empty($by_weekno[$info['-week']])) {
           continue;
         }
       }
 
-      $weeks[$info['week']][] = $info['monthday'];
+      $weeks[$info['week']][] = $info;
     }
 
     return array_values($weeks);
   }
 
   private function newMonthsSet($interval, $set) {
     // NOTE: This value is also a real constant! Wow!
     $months_in_year = 12;
 
     if ($this->cursorMonth > $months_in_year) {
       $this->cursorMonth -= $months_in_year;
       return array();
     }
 
     list($cursor, $result) = $this->newIteratorSet(
       $this->cursorMonth,
       $interval,
       $set,
       $months_in_year + 1);
 
     $this->cursorMonth = ($cursor - $months_in_year);
 
     return $result;
   }
 
   public static function getYearMap($year, $week_start) {
     static $maps = array();
 
     $key = "{$year}/{$week_start}";
     if (isset($maps[$key])) {
       return $maps[$key];
     }
 
     $map = self::newYearMap($year, $week_start);
     $maps[$key] = $map;
 
     return $maps[$key];
   }
 
   private static function newYearMap($year, $weekday_start) {
     $weekday_index = self::getWeekdayIndex($weekday_start);
 
     $is_leap = (($year % 4 === 0) && ($year % 100 !== 0)) ||
                ($year % 400 === 0);
 
     // There may be some clever way to figure out which day of the week a given
     // year starts on and avoid the cost of a DateTime construction, but I
     // wasn't able to turn it up and we only need to do this once per year.
     $datetime = new DateTime("{$year}-01-01", new DateTimeZone('UTC'));
     $weekday = (int)$datetime->format('w');
 
     if ($is_leap) {
       $max_day = 366;
     } else {
       $max_day = 365;
     }
 
     $month_days = array(
       1 => 31,
       2 => $is_leap ? 29 : 28,
       3 => 31,
       4 => 30,
       5 => 31,
       6 => 30,
       7 => 31,
       8 => 31,
       9 => 30,
       10 => 31,
       11 => 30,
       12 => 31,
     );
 
     // Per the spec, the first week of the year must contain at least four
     // days. If the week starts on a Monday but the year starts on a Saturday,
     // the first couple of days don't count as a week. In this case, the first
     // week will begin on January 3.
     $first_week_size = 0;
     $first_weekday = $weekday;
     for ($year_day = 1; $year_day <= $max_day; $year_day++) {
       $first_weekday = ($first_weekday + 1) % 7;
       $first_week_size++;
       if ($first_weekday === $weekday_index) {
         break;
       }
     }
 
     if ($first_week_size >= 4) {
       $week_number = 1;
     } else {
       $week_number = 0;
     }
 
     $info_map = array();
 
     $weekday_map = self::getWeekdayIndexMap();
     $weekday_map = array_flip($weekday_map);
 
     $yearly_counts = array();
     $monthly_counts = array();
 
     $month_number = 1;
     $month_day = 1;
     for ($year_day = 1; $year_day <= $max_day; $year_day++) {
       $key = "{$month_number}M{$month_day}D";
 
       $short_day = $weekday_map[$weekday];
       if (empty($yearly_counts[$short_day])) {
         $yearly_counts[$short_day] = 0;
       }
       $yearly_counts[$short_day]++;
 
       if (empty($monthly_counts[$month_number][$short_day])) {
         $monthly_counts[$month_number][$short_day] = 0;
       }
       $monthly_counts[$month_number][$short_day]++;
 
       $info = array(
         'year' => $year,
         'key' => $key,
         'month' => $month_number,
         'monthday' => $month_day,
         '-monthday' => -$month_days[$month_number] + $month_day - 1,
         'yearday' => $year_day,
         '-yearday' => -$max_day + $year_day - 1,
         'week' => $week_number,
         'weekday' => $short_day,
         'weekday.yearly' => $yearly_counts[$short_day],
         'weekday.monthly' => $monthly_counts[$month_number][$short_day],
       );
 
       $info_map[$key] = $info;
 
       $weekday = ($weekday + 1) % 7;
       if ($weekday === $weekday_index) {
         $week_number++;
       }
 
       $month_day = ($month_day + 1);
       if ($month_day > $month_days[$month_number]) {
         $month_day = 1;
         $month_number++;
       }
     }
 
     // Check how long the final week is. If it doesn't have four days, this
     // is really the first week of the next year.
     $final_week = array();
     foreach ($info_map as $key => $info) {
       if ($info['week'] == $week_number) {
         $final_week[] = $key;
       }
     }
 
     if (count($final_week) < 4) {
       $week_number = $week_number - 1;
       $next_year = self::getYearMap($year + 1, $weekday_start);
       $next_year_weeks = $next_year['weekCount'];
     } else {
       $next_year_weeks = null;
     }
 
     if ($first_week_size < 4) {
       $last_year = self::getYearMap($year - 1, $weekday_start);
       $last_year_weeks = $last_year['weekCount'];
     } else {
       $last_year_weeks = null;
     }
 
     // Now that we know how many weeks the year has, we can compute the
     // negative offsets.
     foreach ($info_map as $key => $info) {
       $week = $info['week'];
 
       if ($week === 0) {
         // If this day is part of the first partial week of the year, give
         // it the week number of the last week of the prior year instead.
         $info['week'] = $last_year_weeks;
         $info['-week'] = -1;
       } else if ($week > $week_number) {
         // If this day is part of the last partial week of the year, give
         // it week numbers from the next year.
         $info['week'] = 1;
         $info['-week'] = -$next_year_weeks;
       } else {
         $info['-week'] = -$week_number + $week - 1;
       }
 
       // Do all the arithmetic to figure out if this is the -19th Thursday
       // in the year and such.
       $month_number = $info['month'];
       $short_day = $info['weekday'];
       $monthly_count = $monthly_counts[$month_number][$short_day];
       $monthly_index = $info['weekday.monthly'];
       $info['-weekday.monthly'] = -$monthly_count + $monthly_index - 1;
       $info['-weekday.monthly'] .= $short_day;
       $info['weekday.monthly'] .= $short_day;
 
       $yearly_count = $yearly_counts[$short_day];
       $yearly_index = $info['weekday.yearly'];
       $info['-weekday.yearly'] = -$yearly_count + $yearly_index - 1;
       $info['-weekday.yearly'] .= $short_day;
       $info['weekday.yearly'] .= $short_day;
 
       $info_map[$key] = $info;
     }
 
+    $week_map = array();
+    foreach ($info_map as $key => $info) {
+      $week_map[$info['week']][] = $key;
+    }
+
     return array(
       'info' => $info_map,
       'weekCount' => $week_number,
       'dayCount' => $max_day,
       'monthDays' => $month_days,
+      'weekMap' => $week_map,
     );
   }
 
   private function newIteratorSet($cursor, $interval, $set, $limit) {
     if ($interval < 1) {
       throw new Exception(
         pht(
           'Invalid iteration interval ("%d"), must be at least 1.',
           $interval));
     }
 
     $result = array();
     $seen = array();
 
     $ii = $cursor;
     while (true) {
       if (!$set || isset($set[$ii])) {
         $result[] = $ii;
       }
 
       $ii = ($ii + $interval);
 
       if ($ii >= $limit) {
         break;
       }
     }
 
     sort($result);
     $result = array_values($result);
 
     return array($ii, $result);
   }
 
   private function applySetPos(array $values, array $setpos) {
     $select = array();
 
     $count = count($values);
     foreach ($setpos as $pos) {
       if ($pos > 0 && $pos <= $count) {
         $select[] = ($pos - 1);
       } else if ($pos < 0 && $pos >= -$count) {
         $select[] = ($count + $pos);
       }
     }
 
     sort($select);
     $select = array_unique($select);
 
     return array_select_keys($values, $select);
   }
 
   private function assertByRange(
     $source,
     array $values,
     $min,
     $max,
     $allow_zero = true) {
 
     foreach ($values as $value) {
       if (!is_int($value)) {
         throw new Exception(
           pht(
             'Value "%s" in RRULE "%s" parameter is invalid: values must be '.
             'integers.',
             $value,
             $source));
       }
 
       if ($value < $min || $value > $max) {
         throw new Exception(
           pht(
             'Value "%s" in RRULE "%s" parameter is invalid: it must be '.
             'between %s and %s.',
             $value,
             $source,
             $min,
             $max));
       }
 
       if (!$value && !$allow_zero) {
         throw new Exception(
           pht(
             'Value "%s" in RRULE "%s" parameter is invalid: it must not '.
             'be zero.',
             $value,
             $source));
       }
     }
   }
 
   private function getSetPositionState() {
     $scale = $this->getFrequencyScale();
 
     $parts = array();
     $parts[] = $this->stateYear;
-    if ($scale < self::SCALE_YEARLY) {
-      $parts[] = $this->stateMonth;
-    }
-    if ($scale < self::SCALE_MONTHLY) {
-      $parts[] = $this->stateDay;
-    }
-    if ($scale < self::SCALE_DAILY) {
-      $parts[] = $this->stateHour;
-    }
-    if ($scale < self::SCALE_HOURLY) {
-      $parts[] = $this->stateMinute;
+
+    if ($scale == self::SCALE_WEEKLY) {
+      $parts[] = $this->stateWeek;
+    } else {
+      if ($scale < self::SCALE_YEARLY) {
+        $parts[] = $this->stateMonth;
+      }
+      if ($scale < self::SCALE_MONTHLY) {
+        $parts[] = $this->stateDay;
+      }
+      if ($scale < self::SCALE_DAILY) {
+        $parts[] = $this->stateHour;
+      }
+      if ($scale < self::SCALE_HOURLY) {
+        $parts[] = $this->stateMinute;
+      }
     }
 
     return implode('/', $parts);
   }
 
   private function rewindMonth() {
     while ($this->cursorMonth < 1) {
       $this->cursorYear--;
       $this->cursorMonth += 12;
     }
   }
 
   private function rewindDay() {
     $week_start = $this->getWeekStart();
     while ($this->cursorDay < 1) {
       $year_map = $this->getYearMap($this->cursorYear, $week_start);
       $this->cursorDay += $year_map['monthDays'][$this->cursorMonth];
       $this->cursorMonth--;
       $this->rewindMonth();
     }
   }
 
   private function rewindHour() {
     while ($this->cursorHour < 0) {
       $this->cursorHour += 24;
       $this->cursorDay--;
       $this->rewindDay();
     }
   }
 
   private function rewindMinute() {
     while ($this->cursorMinute < 0) {
       $this->cursorMinute += 60;
       $this->cursorHour--;
       $this->rewindHour();
     }
   }
 
   private function advanceCursorState(
     array $cursor,
     $scale,
     $interval,
     $week_start) {
 
     $state = array(
       'year' => $this->stateYear,
       'month' => $this->stateMonth,
+      'week' => $this->stateWeek,
       'day' => $this->stateDay,
       'hour' => $this->stateHour,
     );
 
     // In the common case when the interval is 1, we'll visit every possible
     // value so we don't need to do any math and can just jump to the first
     // hour, day, etc.
     if ($interval == 1) {
       if ($this->isCursorBehind($cursor, $state, $scale)) {
         switch ($scale) {
           case self::SCALE_DAILY:
             $this->cursorDay = 1;
             break;
           case self::SCALE_HOURLY:
             $this->cursorHour = 0;
             break;
+          case self::SCALE_WEEKLY:
+            $this->cursorWeek = 1;
+            break;
         }
       }
+
       return array(false, $state);
     }
 
     $year_map = $this->getYearMap($cursor['year'], $week_start);
     while ($this->isCursorBehind($cursor, $state, $scale)) {
       switch ($scale) {
         case self::SCALE_DAILY:
           $cursor['day'] += $interval;
           break;
         case self::SCALE_HOURLY:
           $cursor['hour'] += $interval;
           break;
+        case self::SCALE_WEEKLY:
+          $cursor['week'] += $interval;
+          break;
       }
 
       if ($scale <= self::SCALE_HOURLY) {
         while ($cursor['hour'] >= 24) {
           $cursor['hour'] -= 24;
           $cursor['day']++;
         }
       }
 
+      if ($scale == self::SCALE_WEEKLY) {
+        while ($cursor['week'] > $year_map['weekCount']) {
+          $cursor['week'] -= $year_map['weekCount'];
+          $cursor['year']++;
+          $year_map = $this->getYearMap($cursor['year'], $week_start);
+        }
+      }
+
       if ($scale <= self::SCALE_DAILY) {
         while ($cursor['day'] > $year_map['monthDays'][$cursor['month']]) {
           $cursor['day'] -= $year_map['monthDays'][$cursor['month']];
           $cursor['month']++;
           if ($cursor['month'] > 12) {
             $cursor['month'] -= 12;
             $cursor['year']++;
             $year_map = $this->getYearMap($cursor['year'], $week_start);
           }
         }
       }
     }
 
     switch ($scale) {
       case self::SCALE_DAILY:
         $this->cursorDay = $cursor['day'];
         break;
       case self::SCALE_HOURLY:
         $this->cursorHour = $cursor['hour'];
         break;
+      case self::SCALE_WEEKLY:
+        $this->cursorWeek = $cursor['week'];
+        break;
     }
 
     $skip = $this->isCursorBehind($state, $cursor, $scale);
 
     return array($skip, $cursor);
   }
 
   private function isCursorBehind(array $cursor, array $state, $scale) {
     if ($cursor['year'] < $state['year']) {
       return true;
     } else if ($cursor['year'] > $state['year']) {
       return false;
     }
 
+    if ($scale == self::SCALE_WEEKLY) {
+      return false;
+    }
+
     if ($cursor['month'] < $state['month']) {
       return true;
     } else if ($cursor['month'] > $state['month']) {
       return false;
     }
 
     if ($scale >= self::SCALE_DAILY) {
       return false;
     }
 
     if ($cursor['day'] < $state['day']) {
       return true;
     } else if ($cursor['day'] > $state['day']) {
       return false;
     }
 
     if ($scale >= self::SCALE_HOURLY) {
       return false;
     }
 
     if ($cursor['hour'] < $state['hour']) {
       return true;
     } else if ($cursor['hour'] > $state['hour']) {
       return false;
     }
 
     return false;
   }
 
 
-
 }
diff --git a/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php b/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php
index 02061f6..e903b33 100644
--- a/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php
+++ b/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php
@@ -1,1555 +1,1662 @@
 <?php
 
 final class PhutilCalendarRecurrenceRuleTestCase extends PhutilTestCase {
 
   public function testSimpleRecurrenceRules() {
     $start = PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z');
 
     $rrule = id(new PhutilCalendarRecurrenceRule())
       ->setStartDateTime($start)
       ->setFrequency(PhutilCalendarRecurrenceRule::FREQUENCY_DAILY);
 
     $set = id(new PhutilCalendarRecurrenceSet())
       ->addSource($rrule);
 
     $result = $set->getEventsBetween(null, null, 3);
 
     $expect = array(
       PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'),
       PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120000Z'),
       PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'),
     );
 
     $this->assertEqual(
       mpull($expect, 'getISO8601'),
       mpull($result, 'getISO8601'),
       pht('Simple daily event.'));
 
 
 
     $rrule = id(new PhutilCalendarRecurrenceRule())
       ->setStartDateTime($start)
       ->setFrequency(PhutilCalendarRecurrenceRule::FREQUENCY_HOURLY)
       ->setByHour(array(12, 13));
 
     $set = id(new PhutilCalendarRecurrenceSet())
       ->addSource($rrule);
 
     $result = $set->getEventsBetween(null, null, 5);
 
     $expect = array(
       PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'),
       PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T130000Z'),
       PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120000Z'),
       PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T130000Z'),
       PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'),
     );
 
     $this->assertEqual(
       mpull($expect, 'getISO8601'),
       mpull($result, 'getISO8601'),
       pht('Hourly event with BYHOUR.'));
 
 
     $rrule = id(new PhutilCalendarRecurrenceRule())
       ->setStartDateTime($start)
       ->setFrequency(PhutilCalendarRecurrenceRule::FREQUENCY_YEARLY);
 
     $set = id(new PhutilCalendarRecurrenceSet())
       ->addSource($rrule);
 
     $result = $set->getEventsBetween(null, null, 2);
 
     $expect = array(
       PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'),
       PhutilCalendarAbsoluteDateTime::newFromISO8601('20170101T120000Z'),
     );
 
     $this->assertEqual(
       mpull($expect, 'getISO8601'),
       mpull($result, 'getISO8601'),
       pht('Yearly event.'));
 
 
     // This is an efficiency test for bizarre rules: it defines a secondly
     // event which only occurs one a year, and generates 3 instances of it.
     // This implementation should be fast enough that this test doesn't take
     // a significant amount of time.
 
     $rrule = id(new PhutilCalendarRecurrenceRule())
       ->setStartDateTime($start)
       ->setFrequency(PhutilCalendarRecurrenceRule::FREQUENCY_SECONDLY)
       ->setByMonth(array(1))
       ->setByMonthDay(array(1))
       ->setByHour(array(12))
       ->setByMinute(array(0))
       ->setBySecond(array(0));
 
     $set = id(new PhutilCalendarRecurrenceSet())
       ->addSource($rrule);
 
     $result = $set->getEventsBetween(null, null, 3);
 
     $expect = array(
       PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'),
       PhutilCalendarAbsoluteDateTime::newFromISO8601('20170101T120000Z'),
       PhutilCalendarAbsoluteDateTime::newFromISO8601('20180101T120000Z'),
     );
 
     $this->assertEqual(
       mpull($expect, 'getISO8601'),
       mpull($result, 'getISO8601'),
       pht('Secondly event with many constraints.'));
   }
 
   public function testYearlyRecurrenceRules() {
     $tests = array();
     $expect = array();
 
     $tests[] = array();
     $expect[] = array(
       '19970902',
       '19980902',
       '19990902',
     );
 
     $tests[] = array(
       'INTERVAL' => 2,
     );
     $expect[] = array(
       '19970902',
       '19990902',
       '20010902',
     );
 
     $tests[] = array(
       'DTSTART' => '20000229',
     );
     $expect[] = array(
       '20000229',
       '20040229',
       '20080229',
     );
 
     $tests[] = array(
       'BYMONTH' => array(1, 3),
     );
     $expect[] = array(
       '19980102',
       '19980302',
       '19990102',
     );
 
     $tests[] = array(
       'BYMONTHDAY' => array(1, 3),
     );
     $expect[] = array(
       '19970903',
       '19971001',
       '19971003',
     );
 
     $tests[] = array(
       'BYMONTH' => array(1, 3),
       'BYMONTHDAY' => array(5, 7),
     );
     $expect[] = array(
       '19980105',
       '19980107',
       '19980305',
     );
 
     $tests[] = array(
       'BYDAY' => array('TU', 'TH'),
     );
     $expect[] = array(
       '19970902',
       '19970904',
       '19970909',
     );
 
     $tests[] = array(
       'BYDAY' => array('SU'),
     );
     $expect[] = array(
       '19970907',
       '19970914',
       '19970921',
     );
 
     $tests[] = array(
       'BYMONTH' => array(1, 3),
       'BYDAY' => array('TU', 'TH'),
     );
     $expect[] = array(
       '19980101',
       '19980106',
       '19980108',
     );
 
     $tests[] = array(
       'BYMONTHDAY' => array(1, 3),
       'BYDAY' => array('TU', 'TH'),
     );
     $expect[] = array(
       '19980101',
       '19980203',
       '19980303',
     );
 
     $tests[] = array(
       'BYMONTHDAY' => array(1, 3),
       'BYDAY' => array('TU', 'TH'),
       'BYMONTH' => array(1, 3),
     );
     $expect[] = array(
       '19980101',
       '19980303',
       '20010301',
     );
 
     $tests[] = array(
       'BYDAY' => array('1TU', '-1TH'),
     );
     $expect[] = array(
       '19971225',
       '19980106',
       '19981231',
     );
 
     // Same test as above, just making sure the optional "+" syntax works.
     $tests[] = array(
       'BYDAY' => array('+1TU', '-1TH'),
     );
     $expect[] = array(
       '19971225',
       '19980106',
       '19981231',
     );
 
     $tests[] = array(
       'BYDAY' => array('3TU', '-3TH'),
     );
     $expect[] = array(
       '19971211',
       '19980120',
       '19981217',
     );
 
     $tests[] = array(
       'BYMONTH' => array(1, 3),
       'BYDAY' => array('1TU', '-1TH'),
     );
     $expect[] = array(
       '19980106',
       '19980129',
       '19980303',
     );
 
     $tests[] = array(
       'BYMONTH' => array(1, 3),
       'BYDAY' => array('3TU', '-3TH'),
     );
     $expect[] = array(
       '19980115',
       '19980120',
       '19980312',
     );
 
     $tests[] = array(
       'BYYEARDAY' => array(1, 100, 200, 365),
       'COUNT' => 4,
     );
     $expect[] = array(
       '19971231',
       '19980101',
       '19980410',
       '19980719',
     );
 
     $tests[] = array(
       'BYYEARDAY' => array(-365, -266, -166, -1),
       'COUNT' => 4,
     );
     $expect[] = array(
       '19971231',
       '19980101',
       '19980410',
       '19980719',
     );
 
     $tests[] = array(
       'BYYEARDAY' => array(1, 100, 200, 365),
       'BYMONTH' => array(4, 7),
       'COUNT' => 4,
     );
     $expect[] = array(
       '19980410',
       '19980719',
       '19990410',
       '19990719',
     );
 
     $tests[] = array(
       'BYYEARDAY' => array(-365, -266, -166, -1),
       'BYMONTH' => array(4, 7),
       'COUNT' => 4,
     );
     $expect[] = array(
       '19980410',
       '19980719',
       '19990410',
       '19990719',
     );
 
     $tests[] = array(
       'BYWEEKNO' => array(20),
     );
     $expect[] = array(
       '19980511',
       '19980512',
       '19980513',
     );
 
     $tests[] = array(
       'BYWEEKNO' => array(1),
       'BYDAY' => array('MO'),
     );
     $expect[] = array(
       '19971229',
       '19990104',
       '20000103',
     );
 
     $tests[] = array(
       'BYWEEKNO' => array(52),
       'BYDAY' => array('SU'),
     );
     $expect[] = array(
       '19971228',
       '19981227',
       '20000102',
     );
 
     $tests[] = array(
       'BYWEEKNO' => array(-1),
       'BYDAY' => array('SU'),
     );
     $expect[] = array(
       '19971228',
       '19990103',
       '20000102',
     );
 
     $tests[] = array(
       'BYWEEKNO' => array(53),
       'BYDAY' => array('MO'),
     );
     $expect[] = array(
       '19981228',
       '20041227',
       '20091228',
     );
 
     $tests[] = array(
       'BYHOUR' => array(6, 18),
     );
     $expect[] = array(
       '19970902T060000Z',
       '19970902T180000Z',
       '19980902T060000Z',
     );
 
     $tests[] = array(
       'BYMINUTE' => array(15, 30),
     );
     $expect[] = array(
       '19970902T001500Z',
       '19970902T003000Z',
       '19980902T001500Z',
     );
 
     $tests[] = array(
       'BYSECOND' => array(10, 20),
     );
     $expect[] = array(
       '19970902T000010Z',
       '19970902T000020Z',
       '19980902T000010Z',
     );
 
     $tests[] = array(
       'BYHOUR' => array(6, 18),
       'BYMINUTE' => array(15, 30),
     );
     $expect[] = array(
       '19970902T061500Z',
       '19970902T063000Z',
       '19970902T181500Z',
     );
 
     $tests[] = array(
       'BYHOUR' => array(6, 18),
       'BYSECOND' => array(10, 20),
     );
     $expect[] = array(
       '19970902T060010Z',
       '19970902T060020Z',
       '19970902T180010Z',
     );
 
     $tests[] = array(
       'BYMINUTE' => array(15, 30),
       'BYSECOND' => array(10, 20),
     );
     $expect[] = array(
       '19970902T001510Z',
       '19970902T001520Z',
       '19970902T003010Z',
     );
 
     $tests[] = array(
       'BYHOUR' => array(6, 18),
       'BYMINUTE' => array(15, 30),
       'BYSECOND' => array(10, 20),
     );
     $expect[] = array(
       '19970902T061510Z',
       '19970902T061520Z',
       '19970902T063010Z',
     );
 
     $tests[] = array(
       'BYMONTHDAY' => array(15),
       'BYHOUR' => array(6, 18),
       'BYSETPOS' => array(3, -3),
     );
     $expect[] = array(
       '19971115T180000Z',
       '19980215T060000Z',
       '19981115T180000Z',
     );
 
     $this->assertRules(
       array(
         'FREQ' => 'YEARLY',
         'COUNT' => 3,
         'DTSTART' => '19970902',
       ),
       $tests,
       $expect);
   }
 
   public function testMonthlyRecurrenceRules() {
     $tests = array();
     $expect = array();
 
     $tests[] = array();
     $expect[] = array(
       '19970902',
       '19971002',
       '19971102',
     );
 
     $tests[] = array(
       'INTERVAL' => 2,
     );
     $expect[] = array(
       '19970902',
       '19971102',
       '19980102',
     );
 
     $tests[] = array(
       'INTERVAL' => 18,
     );
     $expect[] = array(
       '19970902',
       '19990302',
       '20000902',
     );
 
     $tests[] = array(
       'BYMONTH' => array(1, 3),
     );
     $expect[] = array(
       '19980102',
       '19980302',
       '19990102',
     );
 
     $tests[] = array(
       'BYMONTHDAY' => array(1, 3),
     );
     $expect[] = array(
       '19970903',
       '19971001',
       '19971003',
     );
 
     $tests[] = array(
       'BYMONTHDAY' => array(5, 7),
       'BYMONTH' => array(1, 3),
     );
     $expect[] = array(
       '19980105',
       '19980107',
       '19980305',
     );
 
     $tests[] = array(
       'BYDAY' => array('TU', 'TH'),
     );
     $expect[] = array(
       '19970902',
       '19970904',
       '19970909',
     );
 
     $tests[] = array(
       'BYDAY' => array('3MO'),
     );
     $expect[] = array(
       '19970915',
       '19971020',
       '19971117',
     );
 
     $tests[] = array(
       'BYDAY' => array('1TU', '-1TH'),
     );
     $expect[] = array(
       '19970902',
       '19970925',
       '19971007',
     );
 
     $tests[] = array(
       'BYDAY' => array('3TU', '-3TH'),
     );
     $expect[] = array(
       '19970911',
       '19970916',
       '19971016',
     );
 
     $tests[] = array(
       'BYDAY' => array('TU', 'TH'),
       'BYMONTH' => array(1, 3),
     );
     $expect[] = array(
       '19980101',
       '19980106',
       '19980108',
     );
 
     $tests[] = array(
       'BYMONTH' => array(1, 3),
       'BYDAY' => array('1TU', '-1TH'),
     );
     $expect[] = array(
       '19980106',
       '19980129',
       '19980303',
     );
 
     $tests[] = array(
       'BYMONTH' => array(1, 3),
       'BYDAY' => array('3TU', '-3TH'),
     );
     $expect[] = array(
       '19980115',
       '19980120',
       '19980312',
     );
 
     $tests[] = array(
       'BYMONTHDAY' => array(1, 3),
       'BYDAY' => array('TU', 'TH'),
     );
     $expect[] = array(
       '19980101',
       '19980203',
       '19980303',
     );
 
     $tests[] = array(
       'BYMONTH' => array(1, 3),
       'BYMONTHDAY' => array(1, 3),
       'BYDAY' => array('TU', 'TH'),
     );
     $expect[] = array(
       '19980101',
       '19980303',
       '20010301',
     );
 
     $tests[] = array(
       'BYDAY' => array('MO', 'TU', 'WE', 'TH', 'FR'),
       'BYSETPOS' => array(-1),
     );
     $expect[] = array(
       '19970930',
       '19971031',
       '19971128',
     );
 
     $tests[] = array(
       'BYDAY' => array('1MO', '1TU', '1WE', '1TH', '1FR', '-1FR'),
       'BYMONTHDAY' => array(1, -1, -2),
     );
     $expect[] = array(
       '19971001',
       '19971031',
       '19971201',
     );
 
     $tests[] = array(
       'BYDAY' => array('1MO', '1TU', '1WE', '1TH', 'FR'),
       'BYMONTHDAY' => array(1, -1, -2),
     );
     $expect[] = array(
       '19971001',
       '19971031',
       '19971201',
     );
 
     $tests[] = array(
       'BYHOUR' => array(6, 18),
     );
     $expect[] = array(
       '19970902T060000Z',
       '19970902T180000Z',
       '19971002T060000Z',
     );
 
     $tests[] = array(
       'BYMINUTE' => array(6, 18),
     );
     $expect[] = array(
       '19970902T000600Z',
       '19970902T001800Z',
       '19971002T000600Z',
     );
 
     $tests[] = array(
       'BYSECOND' => array(6, 18),
     );
     $expect[] = array(
       '19970902T000006Z',
       '19970902T000018Z',
       '19971002T000006Z',
     );
 
     $tests[] = array(
       'BYMONTHDAY' => array(13, 17),
       'BYHOUR' => array(6, 18),
       'BYSETPOS' => array(3, -3),
     );
     $expect[] = array(
       '19970913T180000Z',
       '19970917T060000Z',
       '19971013T180000Z',
     );
 
     $tests[] = array(
       'BYMONTHDAY' => array(13, 17),
       'BYHOUR' => array(6, 18),
       'BYSETPOS' => array(3, 3, -3),
     );
     $expect[] = array(
       '19970913T180000Z',
       '19970917T060000Z',
       '19971013T180000Z',
     );
 
     $tests[] = array(
       'BYMONTHDAY' => array(13, 17),
       'BYHOUR' => array(6, 18),
       'BYSETPOS' => array(4, -1),
     );
     $expect[] = array(
       '19970917T180000Z',
       '19971017T180000Z',
       '19971117T180000Z',
     );
 
     $this->assertRules(
       array(
         'FREQ' => 'MONTHLY',
         'COUNT' => 3,
         'DTSTART' => '19970902',
       ),
       $tests,
       $expect);
   }
 
   public function testDailyRecurrenceRules() {
     $tests = array();
     $expect = array();
 
     $tests[] = array();
     $expect[] = array(
       '19970902',
       '19970903',
       '19970904',
     );
 
     $tests[] = array(
       'INTERVAL' => 2,
     );
     $expect[] = array(
       '19970902',
       '19970904',
       '19970906',
     );
 
     $tests[] = array(
       'INTERVAL' => 92,
     );
     $expect[] = array(
       '19970902',
       '19971203',
       '19980305',
     );
 
     $tests[] = array(
       'BYMONTH' => array(1, 3),
     );
     $expect[] = array(
       '19980101',
       '19980102',
       '19980103',
     );
 
     // This is testing that INTERVAL is respected in the presence of a BYMONTH
     // filter which skips some months.
     $tests[] = array(
       'BYMONTH' => array(12),
       'INTERVAL' => 17,
     );
     $expect[] = array(
       '19971213',
       '19971230',
       '19981205',
     );
 
     $tests[] = array(
       'BYMONTHDAY' => array(1, 3),
     );
     $expect[] = array(
       '19970903',
       '19971001',
       '19971003',
     );
 
     $tests[] = array(
       'BYMONTH' => array(1, 3),
       'BYMONTHDAY' => array(5, 7),
     );
     $expect[] = array(
       '19980105',
       '19980107',
       '19980305',
     );
 
     $tests[] = array(
       'BYDAY' => array('TU', 'TH'),
     );
     $expect[] = array(
       '19970902',
       '19970904',
       '19970909',
     );
 
     $tests[] = array(
       'BYMONTH' => array(1, 3),
       'BYDAY' => array('TU', 'TH'),
     );
     $expect[] = array(
       '19980101',
       '19980106',
       '19980108',
     );
 
     $tests[] = array(
       'BYMONTHDAY' => array(1, 3),
       'BYDAY' => array('TU', 'TH'),
     );
     $expect[] = array(
       '19980101',
       '19980203',
       '19980303',
     );
 
     $tests[] = array(
       'BYMONTH' => array(1, 3),
       'BYMONTHDAY' => array(1, 3),
       'BYDAY' => array('TU', 'TH'),
     );
     $expect[] = array(
       '19980101',
       '19980303',
       '20010301',
     );
 
     $tests[] = array(
       'BYHOUR' => array(6, 18),
       'BYMINUTE' => array(15, 45),
       'BYSETPOS' => array(3, -3),
       'DTSTART' => '19970902T090000Z',
     );
     $expect[] = array(
       '19970902T181500Z',
       '19970903T064500Z',
       '19970903T181500Z',
     );
 
     $this->assertRules(
       array(
         'FREQ' => 'DAILY',
         'COUNT' => 3,
         'DTSTART' => '19970902',
       ),
       $tests,
       $expect);
   }
 
   public function testHourlyRecurrenceRules() {
     $tests = array();
     $expect = array();
 
     $tests[] = array();
     $expect[] = array(
       '19970902T090000Z',
       '19970902T100000Z',
       '19970902T110000Z',
     );
 
     $tests[] = array(
       'INTERVAL' => 2,
     );
     $expect[] = array(
       '19970902T090000Z',
       '19970902T110000Z',
       '19970902T130000Z',
     );
 
     $tests[] = array(
       'INTERVAL' => 769,
     );
     $expect[] = array(
       '19970902T090000Z',
       '19971004T100000Z',
       '19971105T110000Z',
     );
 
     $tests[] = array(
       'BYMONTH' => array(1, 3),
     );
     $expect[] = array(
       '19980101T000000Z',
       '19980101T010000Z',
       '19980101T020000Z',
     );
 
     $tests[] = array(
       'BYMONTHDAY' => array(1, 3),
     );
     $expect[] = array(
       '19970903T000000Z',
       '19970903T010000Z',
       '19970903T020000Z',
     );
 
     $tests[] = array(
       'BYMONTH' => array(1, 3),
       'BYMONTHDAY' => array(5, 7),
     );
     $expect[] = array(
       '19980105T000000Z',
       '19980105T010000Z',
       '19980105T020000Z',
     );
 
     $tests[] = array(
       'BYDAY' => array('TU', 'TH'),
     );
     $expect[] = array(
       '19970902T090000Z',
       '19970902T100000Z',
       '19970902T110000Z',
     );
 
     $tests[] = array(
       'BYMONTH' => array(1, 3),
       'BYDAY' => array('TU', 'TH'),
     );
     $expect[] = array(
       '19980101T000000Z',
       '19980101T010000Z',
       '19980101T020000Z',
     );
 
     $tests[] = array(
       'BYMONTHDAY' => array(1, 3),
       'BYDAY' => array('TU', 'TH'),
     );
     $expect[] = array(
       '19980101T000000Z',
       '19980101T010000Z',
       '19980101T020000Z',
     );
 
     $tests[] = array(
       'BYMONTHDAY' => array(1, 3),
       'BYMONTH' => array(1, 3),
       'BYDAY' => array('TU', 'TH'),
     );
     $expect[] = array(
       '19980101T000000Z',
       '19980101T010000Z',
       '19980101T020000Z',
     );
 
     $tests[] = array(
       'COUNT' => 4,
       'BYYEARDAY' => array(1, 100, 200, 365),
     );
     $expect[] = array(
       '19971231T000000Z',
       '19971231T010000Z',
       '19971231T020000Z',
       '19971231T030000Z',
     );
 
     $tests[] = array(
       'COUNT' => 4,
       'BYYEARDAY' => array(-365, -266, -166, -1),
     );
     $expect[] = array(
       '19971231T000000Z',
       '19971231T010000Z',
       '19971231T020000Z',
       '19971231T030000Z',
     );
 
     $tests[] = array(
       'COUNT' => 4,
       'BYMONTH' => array(4, 7),
       'BYYEARDAY' => array(1, 100, 200, 365),
     );
     $expect[] = array(
       '19980410T000000Z',
       '19980410T010000Z',
       '19980410T020000Z',
       '19980410T030000Z',
     );
 
     $tests[] = array(
       'COUNT' => 4,
       'BYMONTH' => array(4, 7),
       'BYYEARDAY' => array(-365, -266, -166, -1),
     );
     $expect[] = array(
       '19980410T000000Z',
       '19980410T010000Z',
       '19980410T020000Z',
       '19980410T030000Z',
     );
 
     $tests[] = array(
       'BYHOUR' => array(6, 18),
     );
     $expect[] = array(
       '19970902T180000Z',
       '19970903T060000Z',
       '19970903T180000Z',
     );
 
     $tests[] = array(
       'BYMINUTE' => array(15, 45),
       'BYSECOND' => array(15, 45),
       'BYSETPOS' => array(3, -3),
     );
     $expect[] = array(
       '19970902T091545Z',
       '19970902T094515Z',
       '19970902T101545Z',
     );
 
     $this->assertRules(
       array(
         'FREQ' => 'HOURLY',
         'COUNT' => 3,
         'DTSTART' => '19970902T090000Z',
       ),
       $tests,
       $expect);
   }
 
   public function testMinutelyRecurrenceRules() {
     $tests = array();
     $expect = array();
 
     $tests[] = array(
     );
     $expect[] = array(
       '19970902T090000Z',
       '19970902T090100Z',
       '19970902T090200Z',
     );
 
     $tests[] = array(
       'INTERVAL' => 2,
     );
     $expect[] = array(
       '19970902T090000Z',
       '19970902T090200Z',
       '19970902T090400Z',
     );
 
     $tests[] = array(
       'BYHOUR' => array(6, 18),
       'BYMINUTE' => array(6, 18),
       'BYSECOND' => array(6, 18),
     );
     $expect[] = array(
       '19970902T180606Z',
       '19970902T180618Z',
       '19970902T181806Z',
     );
 
     $tests[] = array(
       'BYSECOND' => array(15, 30, 45),
       'BYSETPOS' => array(3, -3),
     );
     $expect[] = array(
       '19970902T090015Z',
       '19970902T090045Z',
       '19970902T090115Z',
     );
 
     $this->assertRules(
       array(
         'FREQ' => 'MINUTELY',
         'COUNT' => 3,
         'DTSTART' => '19970902T090000Z',
       ),
       $tests,
       $expect);
   }
 
   public function testSecondlyRecurrenceRules() {
     $tests = array();
     $expect = array();
 
     $tests[] = array();
     $expect[] = array(
       '19970902T090000Z',
       '19970902T090001Z',
       '19970902T090002Z',
     );
 
     $tests[] = array(
       'INTERVAL' => 2,
     );
     $expect[] = array(
       '19970902T090000Z',
       '19970902T090002Z',
       '19970902T090004Z',
     );
 
     $tests[] = array(
       'INTERVAL' => 90061,
     );
     $expect[] = array(
       '19970902T090000Z',
       '19970903T100101Z',
       '19970904T110202Z',
     );
 
     $tests[] = array(
       'BYSECOND' => array(0),
       'BYMINUTE' => array(1),
       'DTSTART' => '20100322T120100Z',
     );
     $expect[] = array(
       '20100322T120100Z',
       '20100322T130100Z',
       '20100322T140100Z',
     );
 
     $this->assertRules(
       array(
         'FREQ' => 'SECONDLY',
         'COUNT' => 3,
         'DTSTART' => '19970902T090000Z',
       ),
       $tests,
       $expect);
   }
 
   public function testRFC5545RecurrenceRules() {
     // These tests are derived from the examples in RFC5545.
     $tests = array();
     $expect = array();
 
     $tests[] = array(
       'FREQ' => 'DAILY',
       'COUNT' => 10,
       'DTSTART' => '19970902T090000Z',
     );
     $expect[] = array(
       '19970902T090000Z',
       '19970903T090000Z',
       '19970904T090000Z',
       '19970905T090000Z',
       '19970906T090000Z',
       '19970907T090000Z',
       '19970908T090000Z',
       '19970909T090000Z',
       '19970910T090000Z',
       '19970911T090000Z',
     );
 
     $tests[] = array(
       'FREQ' => 'DAILY',
       'INTERVAL' => 2,
       'DTSTART' => '19970902T090000Z',
       'COUNT' => 5,
     );
     $expect[] = array(
       '19970902T090000Z',
       '19970904T090000Z',
       '19970906T090000Z',
       '19970908T090000Z',
       '19970910T090000Z',
     );
 
     $tests[] = array(
       'FREQ' => 'YEARLY',
       'BYMONTH' => array(1),
       'BYDAY' => array('MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'),
       'DTSTART' => '19970902T090000Z',
       'COUNT' => 3,
     );
     $expect[] = array(
       '19980101T090000Z',
       '19980102T090000Z',
       '19980103T090000Z',
     );
 
     $tests[] = array(
       'FREQ' => 'MONTHLY',
       'COUNT' => 3,
       'BYDAY' => array('1FR'),
       'DTSTART' => '19970902T090000Z',
     );
     $expect[] = array(
       '19970905T090000Z',
       '19971003T090000Z',
       '19971107T090000Z',
     );
 
     $tests[] = array(
       'FREQ' => 'MONTHLY',
       'INTERVAL' => 2,
       'COUNT' => 5,
       'BYDAY' => array('1SU', '-1SU'),
       'DTSTART' => '19970902T090000Z',
     );
     $expect[] = array(
       '19970907T090000Z',
       '19970928T090000Z',
       '19971102T090000Z',
       '19971130T090000Z',
       '19980104T090000Z',
     );
 
     $tests[] = array(
       'FREQ' => 'MONTHLY',
       'COUNT' => 6,
       'BYDAY' => array('-2MO'),
       'DTSTART' => '19970902T090000Z',
     );
     $expect[] = array(
       '19970922T090000Z',
       '19971020T090000Z',
       '19971117T090000Z',
       '19971222T090000Z',
       '19980119T090000Z',
       '19980216T090000Z',
     );
 
     $tests[] = array(
       'FREQ' => 'MONTHLY',
       'COUNT' => 6,
       'BYMONTHDAY' => array(-3),
       'DTSTART' => '19970902T090000Z',
     );
     $expect[] = array(
       '19970928T090000Z',
       '19971029T090000Z',
       '19971128T090000Z',
       '19971229T090000Z',
       '19980129T090000Z',
       '19980226T090000Z',
     );
 
     $tests[] = array(
       'FREQ' => 'MONTHLY',
       'COUNT' => 5,
       'BYMONTHDAY' => array(2, 15),
       'DTSTART' => '19970902T090000Z',
     );
     $expect[] = array(
       '19970902T090000Z',
       '19970915T090000Z',
       '19971002T090000Z',
       '19971015T090000Z',
       '19971102T090000Z',
     );
 
     $tests[] = array(
       'FREQ' => 'MONTHLY',
       'COUNT' => 5,
       'BYMONTHDAY' => array(-1, 1),
       'DTSTART' => '19970902T090000Z',
     );
     $expect[] = array(
       '19970930T090000Z',
       '19971001T090000Z',
       '19971031T090000Z',
       '19971101T090000Z',
       '19971130T090000Z',
     );
 
     $tests[] = array(
       'FREQ' => 'MONTHLY',
       'COUNT' => 7,
       'INTERVAL' => 18,
       'BYMONTHDAY' => array(10, 11, 12, 13, 14, 15),
       'DTSTART' => '19970902T090000Z',
     );
     $expect[] = array(
       '19970910T090000Z',
       '19970911T090000Z',
       '19970912T090000Z',
       '19970913T090000Z',
       '19970914T090000Z',
       '19970915T090000Z',
       '19990310T090000Z',
     );
 
     $tests[] = array(
       'FREQ' => 'MONTHLY',
       'COUNT' => 6,
       'INTERVAL' => 2,
       'BYDAY' => array('TU'),
       'DTSTART' => '19970902T090000Z',
     );
     $expect[] = array(
       '19970902T090000Z',
       '19970909T090000Z',
       '19970916T090000Z',
       '19970923T090000Z',
       '19970930T090000Z',
       '19971104T090000Z',
     );
 
     $tests[] = array(
       'FREQ' => 'YEARLY',
       'COUNT' => 10,
       'BYMONTH' => array(6, 7),
       'DTSTART' => '19970610T090000Z',
     );
     $expect[] = array(
       '19970610T090000Z',
       '19970710T090000Z',
       '19980610T090000Z',
       '19980710T090000Z',
       '19990610T090000Z',
       '19990710T090000Z',
       '20000610T090000Z',
       '20000710T090000Z',
       '20010610T090000Z',
       '20010710T090000Z',
     );
 
     $tests[] = array(
       'FREQ' => 'YEARLY',
       'COUNT' => 4,
       'INTERVAL' => 3,
       'BYYEARDAY' => array(1, 100, 200),
       'DTSTART' => '19970101T090000Z',
     );
     $expect[] = array(
       '19970101T090000Z',
       '19970410T090000Z',
       '19970719T090000Z',
       '20000101T090000Z',
     );
 
     $tests[] = array(
       'FREQ' => 'YEARLY',
       'COUNT' => 3,
       'BYDAY' => array('20MO'),
       'DTSTART' => '19970519T090000Z',
     );
     $expect[] = array(
       '19970519T090000Z',
       '19980518T090000Z',
       '19990517T090000Z',
     );
 
     $tests[] = array(
       'FREQ' => 'YEARLY',
       'COUNT' => 3,
       'BYWEEKNO' => array(20),
       'BYDAY' => array('MO'),
       'DTSTART' => '19970512T090000Z',
     );
     $expect[] = array(
       '19970512T090000Z',
       '19980511T090000Z',
       '19990517T090000Z',
     );
 
     $tests[] = array(
       'FREQ' => 'YEARLY',
       'BYDAY' => array('TH'),
       'BYMONTH' => array(3),
       'DTSTART' => '19970313T090000Z',
       'COUNT' => 5,
     );
     $expect[] = array(
       '19970313T090000Z',
       '19970320T090000Z',
       '19970327T090000Z',
       '19980305T090000Z',
       '19980312T090000Z',
     );
 
     $tests[] = array(
       'FREQ' => 'YEARLY',
       'BYDAY' => array('TH'),
       'BYMONTH' => array(6, 7, 8),
       'DTSTART' => '19970101T090000Z',
       'COUNT' => 15,
     );
     $expect[] = array(
       '19970605T090000Z',
       '19970612T090000Z',
       '19970619T090000Z',
       '19970626T090000Z',
       '19970703T090000Z',
       '19970710T090000Z',
       '19970717T090000Z',
       '19970724T090000Z',
       '19970731T090000Z',
       '19970807T090000Z',
       '19970814T090000Z',
       '19970821T090000Z',
       '19970828T090000Z',
       '19980604T090000Z',
       '19980611T090000Z',
     );
 
     $tests[] = array(
       'FREQ' => 'YEARLY',
       'BYDAY' => array('FR'),
       'BYMONTHDAY' => array(13),
       'COUNT' => 4,
       'DTSTART' => '19970902T090000Z',
     );
     $expect[] = array(
       '19980213T090000Z',
       '19980313T090000Z',
       '19981113T090000Z',
       '19990813T090000Z',
     );
 
     $tests[] = array(
       'FREQ' => 'MONTHLY',
       'BYDAY' => array('SA'),
       'BYMONTHDAY' => array(7, 8, 9, 10, 11, 12, 13),
       'COUNT' => 10,
       'DTSTART' => '19970902T090000Z',
     );
     $expect[] = array(
       '19970913T090000Z',
       '19971011T090000Z',
       '19971108T090000Z',
       '19971213T090000Z',
       '19980110T090000Z',
       '19980207T090000Z',
       '19980307T090000Z',
       '19980411T090000Z',
       '19980509T090000Z',
       '19980613T090000Z',
     );
 
     $tests[] = array(
       'FREQ' => 'YEARLY',
       'INTERVAL' => 4,
       'BYMONTH' => array(11),
       'BYDAY' => array('TU'),
       'BYMONTHDAY' => array(2, 3, 4, 5, 6, 7, 8),
       'COUNT' => 6,
       'DTSTART' => '19961105T090000Z',
     );
     $expect[] = array(
       '19961105T090000Z',
       '20001107T090000Z',
       '20041102T090000Z',
       '20081104T090000Z',
       '20121106T090000Z',
       '20161108T090000Z',
     );
 
     $tests[] = array(
       'FREQ' => 'MONTHLY',
       'BYDAY' => array('TU', 'WE', 'TH'),
       'BYSETPOS' => array(3),
       'COUNT' => 3,
       'DTSTART' => '19970904T090000Z',
     );
     $expect[] = array(
       '19970904T090000Z',
       '19971007T090000Z',
       '19971106T090000Z',
     );
 
     $tests[] = array(
       'FREQ' => 'MONTHLY',
       'BYDAY' => array('MO', 'TU', 'WE', 'TH', 'FR'),
       'BYSETPOS' => array(-2),
       'COUNT' => 3,
       'DTSTART' => '19970929T090000Z',
     );
     $expect[] = array(
       '19970929T090000Z',
       '19971030T090000Z',
       '19971127T090000Z',
     );
 
     $tests[] = array(
       'FREQ' => 'HOURLY',
       'INTERVAL' => 3,
       'DTSTART' => '19970929T090000Z',
       'COUNT' => 3,
     );
     $expect[] = array(
       '19970929T090000Z',
       '19970929T120000Z',
       '19970929T150000Z',
     );
 
     $tests[] = array(
       'FREQ' => 'MINUTELY',
       'INTERVAL' => 15,
       'COUNT' => 6,
       'DTSTART' => '19970902T090000Z',
     );
     $expect[] = array(
       '19970902T090000Z',
       '19970902T091500Z',
       '19970902T093000Z',
       '19970902T094500Z',
       '19970902T100000Z',
       '19970902T101500Z',
     );
 
     $tests[] = array(
       'FREQ' => 'MINUTELY',
       'INTERVAL' => 90,
       'COUNT' => 4,
       'DTSTART' => '19970902T090000Z',
     );
     $expect[] = array(
       '19970902T090000Z',
       '19970902T103000Z',
       '19970902T120000Z',
       '19970902T133000Z',
     );
 
+    $tests[] = array(
+      'FREQ' => 'WEEKLY',
+      'COUNT' => 10,
+      'DTSTART' => '19970902T090000Z',
+    );
+    $expect[] = array(
+      '19970902T090000Z',
+      '19970909T090000Z',
+      '19970916T090000Z',
+      '19970923T090000Z',
+      '19970930T090000Z',
+      '19971007T090000Z',
+      '19971014T090000Z',
+      '19971021T090000Z',
+      '19971028T090000Z',
+      '19971104T090000Z',
+    );
+
+    $tests[] = array(
+      'FREQ' => 'WEEKLY',
+      'INTERVAL' => 2,
+      'COUNT' => 6,
+      'DTSTART' => '19970902T090000Z',
+    );
+    $expect[] = array(
+      '19970902T090000Z',
+      '19970916T090000Z',
+      '19970930T090000Z',
+      '19971014T090000Z',
+      '19971028T090000Z',
+      '19971111T090000Z',
+    );
+
+    $tests[] = array(
+      'FREQ' => 'WEEKLY',
+      'COUNT' => 10,
+      'WKST' => 'SU',
+      'BYDAY' => array('TU', 'TH'),
+      'DTSTART' => '19970902T090000Z',
+    );
+    $expect[] = array(
+      '19970902T090000Z',
+      '19970904T090000Z',
+      '19970909T090000Z',
+      '19970911T090000Z',
+      '19970916T090000Z',
+      '19970918T090000Z',
+      '19970923T090000Z',
+      '19970925T090000Z',
+      '19970930T090000Z',
+      '19971002T090000Z',
+    );
+
+    $tests[] = array(
+      'FREQ' => 'WEEKLY',
+      'INTERVAL' => 2,
+      'COUNT' => 8,
+      'WKST' => 'SU',
+      'BYDAY' => array('TU', 'TH'),
+      'DTSTART' => '19970902T090000Z',
+    );
+    $expect[] = array(
+      '19970902T090000Z',
+      '19970904T090000Z',
+      '19970916T090000Z',
+      '19970918T090000Z',
+      '19970930T090000Z',
+      '19971002T090000Z',
+      '19971014T090000Z',
+      '19971016T090000Z',
+    );
+
+    $tests[] = array(
+      'FREQ' => 'WEEKLY',
+      'INTERVAL' => 2,
+      'COUNT' => 4,
+      'BYDAY' => array('TU', 'SU'),
+      'WKST' => 'MO',
+      'DTSTART' => '19970805T090000Z',
+    );
+    $expect[] = array(
+      '19970805T090000Z',
+      '19970810T090000Z',
+      '19970819T090000Z',
+      '19970824T090000Z',
+    );
+
+    $tests[] = array(
+      'FREQ' => 'WEEKLY',
+      'INTERVAL' => 2,
+      'COUNT' => 4,
+      'BYDAY' => array('TU', 'SU'),
+      'WKST' => 'SU',
+      'DTSTART' => '19970805T090000Z',
+    );
+    $expect[] = array(
+      '19970805T090000Z',
+      '19970817T090000Z',
+      '19970819T090000Z',
+      '19970831T090000Z',
+    );
+
 
     $this->assertRules(array(), $tests, $expect);
   }
 
 
   private function assertRules(array $defaults, array $tests, array $expect) {
     foreach ($tests as $key => $test) {
       $options = $test + $defaults;
 
       $start = PhutilCalendarAbsoluteDateTime::newFromISO8601(
         $options['DTSTART']);
 
       $rrule = id(new PhutilCalendarRecurrenceRule())
         ->setStartDateTime($start)
         ->setFrequency($options['FREQ']);
 
       $interval = idx($options, 'INTERVAL');
       if ($interval) {
         $rrule->setInterval($interval);
       }
 
       $by_day = idx($options, 'BYDAY');
       if ($by_day) {
         $rrule->setByDay($by_day);
       }
 
       $by_month = idx($options, 'BYMONTH');
       if ($by_month) {
         $rrule->setByMonth($by_month);
       }
 
       $by_monthday = idx($options, 'BYMONTHDAY');
       if ($by_monthday) {
         $rrule->setByMonthDay($by_monthday);
       }
 
       $by_yearday = idx($options, 'BYYEARDAY');
       if ($by_yearday) {
         $rrule->setByYearDay($by_yearday);
       }
 
       $by_weekno = idx($options, 'BYWEEKNO');
       if ($by_weekno) {
         $rrule->setByWeekNumber($by_weekno);
       }
 
       $by_hour = idx($options, 'BYHOUR');
       if ($by_hour) {
         $rrule->setByHour($by_hour);
       }
 
       $by_minute = idx($options, 'BYMINUTE');
       if ($by_minute) {
         $rrule->setByMinute($by_minute);
       }
 
       $by_second = idx($options, 'BYSECOND');
       if ($by_second) {
         $rrule->setBySecond($by_second);
       }
 
       $by_setpos = idx($options, 'BYSETPOS');
       if ($by_setpos) {
         $rrule->setBySetPosition($by_setpos);
       }
 
+      $week_start = idx($options, 'WKST');
+      if ($week_start) {
+        $rrule->setWeekStart($week_start);
+      }
+
       $set = id(new PhutilCalendarRecurrenceSet())
         ->addSource($rrule);
 
       $result = $set->getEventsBetween(null, null, $options['COUNT']);
 
       $this->assertEqual(
         $expect[$key],
         mpull($result, 'getISO8601'));
     }
   }
 
 
 }