diff --git a/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php b/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php
--- a/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php
+++ b/src/parser/calendar/data/PhutilCalendarRecurrenceRule.php
@@ -5,19 +5,18 @@
 
   private $startDateTime;
   private $frequency;
-  private $until;
-  private $count;
+  private $frequencyScale;
   private $interval = 1;
-  private $bySecond;
-  private $byMinute;
-  private $byHour;
-  private $byDay;
-  private $byMonthDay;
-  private $byYearDay;
-  private $byWeekNumber;
-  private $byMonth;
-  private $bySetPosition;
-  private $weekStart = 'MO';
+  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;
@@ -41,7 +40,69 @@
   private $stateMonth;
   private $stateYear;
 
-  private $maps = array();
+  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.',
+          implode(', ', $constants)));
+    }
+
+    return $map[$weekday];
+  }
 
   public function setStartDateTime(PhutilCalendarDateTime $start) {
     $this->startDateTime = $start;
@@ -53,7 +114,27 @@
   }
 
   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 FREQUENCY "%s" is invalid. Valid frequencies are: %s.',
+          $frequency,
+          implode(', ', array_keys($map))));
+    }
+
     $this->frequency = $frequency;
+    $this->frequencyScale = $map[$frequency];
+
     return $this;
   }
 
@@ -61,25 +142,25 @@
     return $this->frequency;
   }
 
-  public function setUntil(PhutilCalendarDateTime $until) {
-    $this->until = $until;
-    return $this;
-  }
-
-  public function getUntil() {
-    return $this->until;
+  public function getFrequencyScale() {
+    return $this->frequencyScale;
   }
 
-  public function setCount($count) {
-    $this->count = $count;
-    return $this;
-  }
+  public function setInterval($interval) {
+    if (!is_int($interval)) {
+      throw new Exception(
+        pht(
+          'RRULE INTERVAL "%s" is invalid: interval must be an integer.',
+          $interval));
+    }
 
-  public function getCount() {
-    return $this->count;
-  }
+    if ($interval < 1) {
+      throw new Exception(
+        pht(
+          'RRULE INTERVAL "%s" is invalid: interval must be 1 or more.',
+          $interval));
+    }
 
-  public function setInterval($interval) {
     $this->interval = $interval;
     return $this;
   }
@@ -88,8 +169,9 @@
     return $this->interval;
   }
 
-  public function setBySecond($by_second) {
-    $this->bySecond = $by_second;
+  public function setBySecond(array $by_second) {
+    $this->assertByRange('BYSECOND', $by_second, 0, 60);
+    $this->bySecond = array_fuse($by_second);
     return $this;
   }
 
@@ -97,8 +179,9 @@
     return $this->bySecond;
   }
 
-  public function setByMinute($by_minute) {
-    $this->byMinute = $by_minute;
+  public function setByMinute(array $by_minute) {
+    $this->assertByRange('BYMINUTE', $by_minute, 0, 59);
+    $this->byMinute = array_fuse($by_minute);
     return $this;
   }
 
@@ -106,8 +189,9 @@
     return $this->byMinute;
   }
 
-  public function setByHour($by_hour) {
-    $this->byHour = $by_hour;
+  public function setByHour(array $by_hour) {
+    $this->assertByRange('BYHOUR', $by_hour, 0, 23);
+    $this->byHour = array_fuse($by_hour);
     return $this;
   }
 
@@ -115,8 +199,38 @@
     return $this->byHour;
   }
 
-  public function setByDay($by_day) {
-    $this->byDay = $by_day;
+  public function setByDay(array $by_day) {
+    $constants = self::getAllWeekdayConstants();
+    $constants = implode('|', $constants);
+
+    $pattern = '/^(?:[+-]?([1-9]\d?))?('.$constants.')\z/';
+    foreach ($by_day as $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));
+      }
+    }
+
+    $this->byDay = array_fuse($by_day);
     return $this;
   }
 
@@ -124,8 +238,9 @@
     return $this->byDay;
   }
 
-  public function setByMonthDay($by_month_day) {
-    $this->byMonthDay = $by_month_day;
+  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;
   }
 
@@ -134,7 +249,8 @@
   }
 
   public function setByYearDay($by_year_day) {
-    $this->byYearDay = $by_year_day;
+    $this->assertByRange('BYYEARDAY', $by_year_day, -366, 366, false);
+    $this->byYearDay = array_fuse($by_year_day);
     return $this;
   }
 
@@ -142,8 +258,9 @@
     return $this->byYearDay;
   }
 
-  public function setByMonth($by_month) {
-    $this->byMonth = $by_month;
+  public function setByMonth(array $by_month) {
+    $this->assertByRange('BYMONTH', $by_month, 1, 12);
+    $this->byMonth = array_fuse($by_month);
     return $this;
   }
 
@@ -151,7 +268,8 @@
     return $this->byMonth;
   }
 
-  public function setByWeekNumber($by_week_number) {
+  public function setByWeekNumber(array $by_week_number) {
+    $this->assertByRange('BYWEEKNO', $by_week_number, -53, 53, false);
     $this->byWeekNumber = $by_week_number;
     return $this;
   }
@@ -160,7 +278,8 @@
     return $this->byWeekNumber;
   }
 
-  public function setBySetPosition($by_set_position) {
+  public function setBySetPosition(array $by_set_position) {
+    $this->assertByRange('BYSETPOS', $by_set_position, -366, 366, false);
     $this->bySetPosition = $by_set_position;
     return $this;
   }
@@ -170,6 +289,9 @@
   }
 
   public function setWeekStart($week_start) {
+    // Make sure this is a valid weekday constant.
+    self::getWeekdayIndex($week_start);
+
     $this->weekStart = $week_start;
     return $this;
   }
@@ -178,7 +300,6 @@
     return $this->weekStart;
   }
 
-
   public function resetSource() {
     $date = $this->getStartDateTime();
 
@@ -244,7 +365,7 @@
 
     $frequency = $this->getFrequency();
     $interval = $this->getInterval();
-    $is_secondly = ($frequency == 'SECONDLY');
+    $is_secondly = ($frequency == self::FREQUENCY_SECONDLY);
     $by_second = $this->getBySecond();
     $by_setpos = $this->getBySetPosition();
 
@@ -279,8 +400,8 @@
 
     $frequency = $this->getFrequency();
     $interval = $this->getInterval();
-    $is_secondly = ($frequency === 'SECONDLY');
-    $is_minutely = ($frequency === 'MINUTELY');
+    $scale = $this->getFrequencyScale();
+    $is_minutely = ($frequency === self::FREQUENCY_MINUTELY);
     $by_minute = $this->getByMinute();
     $by_setpos = $this->getBySetPosition();
 
@@ -291,7 +412,7 @@
         $minutes = $this->newMinutesSet(
           ($is_minutely ? $interval : 1),
           $by_minute);
-      } else if ($is_secondly) {
+      } else if ($scale < self::SCALE_MINUTELY) {
         $minutes = $this->newMinutesSet(
           1,
           array());
@@ -319,20 +440,19 @@
 
     $frequency = $this->getFrequency();
     $interval = $this->getInterval();
-    $is_secondly = ($frequency === 'SECONDLY');
-    $is_minutely = ($frequency === 'MINUTELY');
-    $is_hourly = ($frequency === 'HOURLY');
+    $scale = $this->getFrequencyScale();
+    $is_hourly = ($frequency === self::FREQUENCY_HOURLY);
     $by_hour = $this->getByHour();
     $by_setpos = $this->getBySetPosition();
 
     while (!$this->setHours) {
       $this->nextDay();
 
-      if ($is_minutely || $by_hour) {
+      if ($is_hourly || $by_hour) {
         $hours = $this->newHoursSet(
           ($is_hourly ? $interval : 1),
           $by_hour);
-      } else if ($is_secondly || $is_minutely) {
+      } else if ($scale < self::SCALE_HOURLY) {
         $hours = $this->newHoursSet(
           1,
           array());
@@ -360,11 +480,9 @@
 
     $frequency = $this->getFrequency();
     $interval = $this->getInterval();
-    $is_secondly = ($frequency === 'SECONDLY');
-    $is_minutely = ($frequency === 'MINUTELY');
-    $is_hourly = ($frequency === 'HOURLY');
-    $is_daily = ($frequency === 'DAILY');
-    $is_weekly = ($frequency === 'WEEKLY');
+    $scale = $this->getFrequencyScale();
+    $is_daily = ($frequency === self::FREQUENCY_DAILY);
+    $is_weekly = ($frequency === self::FREQUENCY_WEEKLY);
 
     $by_day = $this->getByDay();
     $by_monthday = $this->getByMonthDay();
@@ -384,8 +502,7 @@
           $by_yearday,
           $by_weekno,
           $this->getWeekStart());
-      } else if ($is_secondly || $is_minutely || $is_hourly) {
-        $all_values = true;
+      } else if ($scale < self::SCALE_DAILY) {
         $weeks = $this->newDaysSet(
           1,
           null,
@@ -427,12 +544,8 @@
 
     $frequency = $this->getFrequency();
     $interval = $this->getInterval();
-    $is_secondly = ($frequency === 'SECONDLY');
-    $is_minutely = ($frequency === 'MINUTELY');
-    $is_hourly = ($frequency === 'HOURLY');
-    $is_daily = ($frequency === 'DAILY');
-    $is_weekly = ($frequency === 'WEEKLY');
-    $is_monthly = ($frequency === 'MONTHLY');
+    $scale = $this->getFrequencyScale();
+    $is_monthly = ($frequency === self::FREQUENCY_MONTHLY);
 
     $by_month = $this->getByMonth();
     $by_setpos = $this->getBySetPosition();
@@ -444,9 +557,7 @@
         $months = $this->newMonthsSet(
           ($is_monthly ? $interval : 1),
           $by_month);
-      } else if (
-          $is_secondly || $is_minutely || $is_hourly ||
-          $is_daily || $is_weekly) {
+      } else if ($scale < self::SCALE_MONTHLY) {
         $months = $this->newMonthsSet(
           1,
           array());
@@ -470,7 +581,7 @@
     $this->stateYear = $this->cursorYear;
 
     $frequency = $this->getFrequency();
-    $is_yearly = ($frequency === 'YEARLY');
+    $is_yearly = ($frequency === self::FREQUENCY_YEARLY);
 
     if ($is_yearly) {
       $interval = $this->getInterval();
@@ -482,7 +593,7 @@
   }
 
   private function newSecondsSet($interval, $set) {
-    // TODO: This doesn't account for leap sections. In theory, it probably
+    // 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;
 
@@ -599,22 +710,6 @@
       }
     }
 
-    if ($by_day) {
-      $by_day = array_fuse($by_day);
-    }
-
-    if ($by_monthday) {
-      $by_monthday = array_fuse($by_monthday);
-    }
-
-    if ($by_yearday) {
-      $by_yearday = array_fuse($by_yearday);
-    }
-
-    if ($by_weekno) {
-      $by_weekno = array_fuse($by_weekno);
-    }
-
     $weeks = array();
     foreach ($selection as $key => $info) {
       if ($info['month'] != $this->cursorMonth) {
@@ -622,7 +717,12 @@
       }
 
       if ($by_day) {
-        // TODO: Implement weekday stuff.
+        // TODO: This only handles "BYDAY=MO,TU". It does not yet properly
+        // handle "BYDAY=+1FR" (e.g., the first Friday in the month).
+
+        if (empty($by_day[$info['weekday']])) {
+          continue;
+        }
       }
 
       if ($by_monthday) {
@@ -640,7 +740,10 @@
       }
 
       if ($by_weekno) {
-        // TODO: Implement week number stuff.
+        if (empty($by_weekno[$info['week']]) &&
+            empty($by_weekno[$info['-week']])) {
+          continue;
+        }
       }
 
       $weeks[$info['week']][] = $info['monthday'];
@@ -669,19 +772,23 @@
     return $result;
   }
 
-  private function getYearMap($year, $week_start) {
+  public static function getYearMap($year, $week_start) {
+    static $maps = array();
+
+    $weekday_index = self::getWeekdayIndex($week_start);
+
     $key = "{$year}/{$week_start}";
-    if (isset($this->maps[$key])) {
-      return $this->maps[$key];
+    if (isset($maps[$key])) {
+      return $maps[$key];
     }
 
-    $map = self::newYearMap($year, $week_start);
-    $this->maps[$key] = $map;
+    $map = self::newYearMap($year, $weekday_index);
+    $maps[$key] = $map;
 
-    return $this->maps[$key];
+    return $maps[$key];
   }
 
-  public static function newYearMap($year, $week_start) {
+  private static function newYearMap($year, $weekday_index) {
     $is_leap = (($year % 4 === 0) && ($year % 100 !== 0)) ||
                ($year % 400 === 0);
 
@@ -691,8 +798,6 @@
     $datetime = new DateTime("{$year}-01-01", new DateTimeZone('UTC'));
     $weekday = $datetime->format('w');
 
-    // TODO: Week 1 must contain at least 4 days!
-
     if ($is_leap) {
       $max_day = 366;
     } else {
@@ -714,16 +819,38 @@
       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;
+      if ($first_weekday === $weekday_index) {
+        break;
+      }
+      $first_week_size++;
+    }
+
+    if ($first_week_size >= 4) {
+      $week_number = 1;
+    } else {
+      $week_number = 0;
+    }
+
     $info_map = array();
     $calendar_map = array();
     $week_map = array();
     $yearday_map = array();
 
+    $weekday_map = self::getWeekdayIndexMap();
+    $weekday_map = array_flip($weekday_map);
+
     $month_number = 1;
     $month_day = 1;
-    $week_number = 1;
     for ($year_day = 1; $year_day <= $max_day; $year_day++) {
-      $key = $month_number.'/'.$month_day;
+      $key = "{$month_number}M{$month_day}D";
 
       $info = array(
         'key' => $key,
@@ -733,6 +860,7 @@
         'yearday' => $year_day,
         '-yearday' => -$max_day + $year_day - 1,
         'week' => $week_number,
+        'weekday' => $weekday_map[$weekday],
       );
 
       $info_map[$key] = $info;
@@ -741,7 +869,7 @@
       $yearday_map[$year_day] = $info;
 
       $weekday = ($weekday + 1) % 7;
-      if ($weekday === $week_start) {
+      if ($weekday === $weekday_index) {
         $week_number++;
       }
 
@@ -752,6 +880,23 @@
       }
     }
 
+    // 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) {
+        // If this day is part of the "zeroth" week of the year, it does not
+        // get a reverse index. In particular, it is not week "-53" (ethe
+        // 53rd week from the end of the year) in a 52-week year.
+        $week_value = 0;
+      } else {
+        $week_value = -$week_number + $week - 1;
+      }
+
+      $info['-week'] = $week_value;
+    }
+
     return array(
       'info' => $info_map,
       'calendar' => $calendar_map,
@@ -771,12 +916,6 @@
           $interval));
     }
 
-    if ($set) {
-      $set = array_fuse($set);
-    } else {
-      $set = array();
-    }
-
     $result = array();
     $seen = array();
 
@@ -815,5 +954,43 @@
     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));
+      }
+    }
+  }
 
 }
diff --git a/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php b/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php
--- a/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php
+++ b/src/parser/calendar/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php
@@ -7,7 +7,7 @@
 
     $rrule = id(new PhutilCalendarRecurrenceRule())
       ->setStartDateTime($start)
-      ->setFrequency('DAILY');
+      ->setFrequency(PhutilCalendarRecurrenceRule::FREQUENCY_DAILY);
 
     $set = id(new PhutilCalendarRecurrenceSet())
       ->addSource($rrule);
@@ -29,7 +29,7 @@
 
     $rrule = id(new PhutilCalendarRecurrenceRule())
       ->setStartDateTime($start)
-      ->setFrequency('HOURLY')
+      ->setFrequency(PhutilCalendarRecurrenceRule::FREQUENCY_HOURLY)
       ->setByHour(array(12, 13));
 
     $set = id(new PhutilCalendarRecurrenceSet())
@@ -53,7 +53,7 @@
 
     $rrule = id(new PhutilCalendarRecurrenceRule())
       ->setStartDateTime($start)
-      ->setFrequency('YEARLY');
+      ->setFrequency(PhutilCalendarRecurrenceRule::FREQUENCY_YEARLY);
 
     $set = id(new PhutilCalendarRecurrenceSet())
       ->addSource($rrule);
@@ -78,7 +78,7 @@
 
     $rrule = id(new PhutilCalendarRecurrenceRule())
       ->setStartDateTime($start)
-      ->setFrequency('SECONDLY')
+      ->setFrequency(PhutilCalendarRecurrenceRule::FREQUENCY_SECONDLY)
       ->setByMonth(array(1))
       ->setByMonthDay(array(1))
       ->setByHour(array(12))