diff --git a/src/parser/calendar/data/PhutilCalendarAbsoluteDateTime.php b/src/parser/calendar/data/PhutilCalendarAbsoluteDateTime.php
--- a/src/parser/calendar/data/PhutilCalendarAbsoluteDateTime.php
+++ b/src/parser/calendar/data/PhutilCalendarAbsoluteDateTime.php
@@ -75,6 +75,92 @@
       ->setTimezone($timezone);
   }
 
+  public static function newFromDictionary(array $dict) {
+    static $keys;
+    if ($keys === null) {
+      $keys = array_fuse(
+        array(
+          'kind',
+          'year',
+          'month',
+          'day',
+          'hour',
+          'minute',
+          'second',
+          'timezone',
+          'isAllDay',
+        ));
+    }
+
+    foreach ($dict as $key => $value) {
+      if (!isset($keys[$key])) {
+        throw new Exception(
+          pht(
+            'Unexpected key "%s" in datetime dictionary, expected keys: %s.',
+            $key,
+            implode(', ', array_keys($keys))));
+      }
+    }
+
+    if (idx($dict, 'kind') !== 'absolute') {
+      throw new Exception(
+        pht(
+          'Expected key "%s" with value "%s" in datetime dictionary.',
+          'kind',
+          'absolute'));
+    }
+
+    if (!isset($dict['year'])) {
+      throw new Exception(
+        pht(
+          'Expected key "%s" in datetime dictionary.',
+          'year'));
+    }
+
+    $datetime = id(new self())
+      ->setYear(idx($dict, 'year'))
+      ->setMonth(idx($dict, 'month', 1))
+      ->setDay(idx($dict, 'day', 1))
+      ->setHour(idx($dict, 'hour', 0))
+      ->setMinute(idx($dict, 'minute', 0))
+      ->setSecond(idx($dict, 'second', 0))
+      ->setTimezone(idx($dict, 'timezone'))
+      ->setIsAllDay(idx($dict, 'isAllDay', false));
+
+    return $datetime;
+  }
+
+  public function newRelativeDateTime($duration) {
+    if (is_string($duration)) {
+      $duration = PhutilCalendarDuration::newFromISO8601($duration);
+    }
+
+    if (!($duration instanceof PhutilCalendarDuration)) {
+      throw new Exception(
+        pht(
+          'Expected "PhutilCalendarDuration" object or ISO8601 duration '.
+          'string.'));
+    }
+
+    return id(new PhutilCalendarRelativeDateTime())
+      ->setOrigin($this)
+      ->setDuration($duration);
+  }
+
+  public function toDictionary() {
+    return array(
+      'kind' => 'absolute',
+      'year' => $this->getYear(),
+      'month' => $this->getMonth(),
+      'day' => $this->getDay(),
+      'hour' => $this->getHour(),
+      'minute' => $this->getMinute(),
+      'second' => $this->getSecond(),
+      'timezone' => $this->getTimezone(),
+      'isAllDay' => $this->getIsAllDay(),
+    );
+  }
+
   public function setYear($year) {
     $this->year = $year;
     return $this;
diff --git a/src/parser/calendar/data/PhutilCalendarDateTime.php b/src/parser/calendar/data/PhutilCalendarDateTime.php
--- a/src/parser/calendar/data/PhutilCalendarDateTime.php
+++ b/src/parser/calendar/data/PhutilCalendarDateTime.php
@@ -31,16 +31,31 @@
 
   public function getISO8601() {
     $datetime = $this->newPHPDateTime();
-    $datetime->setTimezone(new DateTimeZone('UTC'));
 
     if ($this->getIsAllDay()) {
       return $datetime->format('Ymd');
-    } else {
+    } else if ($this->getTimezone()) {
+      // With a timezone, the event occurs at a specific second universally.
+      // We return the UTC representation of that point in time.
+      $datetime->setTimezone(new DateTimeZone('UTC'));
       return $datetime->format('Ymd\\THis\\Z');
+    } else {
+      // With no timezone, events are "floating" and occur at local time.
+      // We return a representation without the "Z".
+      return $datetime->format('Ymd\\THis');
     }
   }
 
+  public function newAbsoluteDateTime() {
+    $epoch = $this->getEpoch();
+    $timezone = $this->getTimezone();
+    return PhutilCalendarAbsoluteDateTime::newFromEpoch($epoch, $timezone)
+      ->setIsAllDay($this->getIsAllDay())
+      ->setViewerTimezone($this->getViewerTimezone());
+  }
+
   abstract protected function newPHPDateTimeZone();
   abstract protected function newPHPDateTime();
 
+  abstract public function getTimezone();
 }
diff --git a/src/parser/calendar/data/PhutilCalendarDuration.php b/src/parser/calendar/data/PhutilCalendarDuration.php
--- a/src/parser/calendar/data/PhutilCalendarDuration.php
+++ b/src/parser/calendar/data/PhutilCalendarDuration.php
@@ -3,12 +3,127 @@
 final class PhutilCalendarDuration extends Phobject {
 
   private $isNegative = false;
-  private $days = 0;
   private $weeks = 0;
+  private $days = 0;
   private $hours = 0;
   private $minutes = 0;
   private $seconds = 0;
 
+  public static function newFromDictionary(array $dict) {
+    static $keys;
+    if ($keys === null) {
+      $keys = array_fuse(
+        array(
+          'isNegative',
+          'weeks',
+          'days',
+          'hours',
+          'minutes',
+          'seconds',
+        ));
+    }
+
+    foreach ($dict as $key => $value) {
+      if (!isset($keys[$key])) {
+        throw new Exception(
+          pht(
+            'Unexpected key "%s" in duration dictionary, expected keys: %s.',
+            $key,
+            implode(', ', array_keys($keys))));
+      }
+    }
+
+    $duration = id(new self())
+      ->setIsNegative(idx($dict, 'isNegative', false))
+      ->setWeeks(idx($dict, 'weeks', 0))
+      ->setDays(idx($dict, 'days', 0))
+      ->setHours(idx($dict, 'hours', 0))
+      ->setMinutes(idx($dict, 'minutes', 0))
+      ->setSeconds(idx($dict, 'seconds', 0));
+
+    return $duration;
+  }
+
+  public function toDictionary() {
+    return array(
+      'isNegative' => $this->getIsNegative(),
+      'weeks' => $this->getWeeks(),
+      'days' => $this->getDays(),
+      'hours' => $this->getHours(),
+      'minutes' => $this->getMinutes(),
+      'seconds' => $this->getSeconds(),
+    );
+  }
+
+  public static function newFromISO8601($value) {
+    $pattern =
+      '/^'.
+      '(?P<sign>[+-])?'.
+      'P'.
+      '(?:'.
+        '(?P<W>\d+)W'.
+        '|'.
+        '(?:(?:(?P<D>\d+)D)?'.
+          '(?:T(?:(?P<H>\d+)H)?(?:(?P<M>\d+)M)?(?:(?P<S>\d+)S)?)?'.
+        ')'.
+      ')'.
+      '\z/';
+
+    $matches = null;
+    $ok = preg_match($pattern, $value, $matches);
+    if (!$ok) {
+      throw new Exception(
+        pht(
+          'Expected ISO8601 duration in the format "P12DT3H4M5S", found '.
+          '"%s".',
+          $value));
+    }
+
+    $is_negative = (idx($matches, 'sign') == '-');
+
+    return id(new self())
+      ->setIsNegative($is_negative)
+      ->setWeeks((int)idx($matches, 'W', 0))
+      ->setDays((int)idx($matches, 'D', 0))
+      ->setHours((int)idx($matches, 'H', 0))
+      ->setMinutes((int)idx($matches, 'M', 0))
+      ->setSeconds((int)idx($matches, 'S', 0));
+  }
+
+  public function toISO8601() {
+    $parts = array();
+    $parts[] = 'P';
+
+    $weeks = $this->getWeeks();
+    if ($weeks) {
+      $parts[] = $weeks.'W';
+    } else {
+      $days = $this->getDays();
+      if ($days) {
+        $parts[] = $days.'D';
+      }
+
+      $parts[] = 'T';
+
+      $hours = $this->getHours();
+      if ($hours) {
+        $parts[] = $hours.'H';
+      }
+
+      $minutes = $this->getMinutes();
+      if ($minutes) {
+        $parts[] = $minutes.'M';
+      }
+
+      $seconds = $this->getSeconds();
+      if ($seconds) {
+        $parts[] = $seconds.'S';
+      }
+    }
+
+    return implode('', $parts);
+  }
+
   public function setIsNegative($is_negative) {
     $this->isNegative = $is_negative;
     return $this;
@@ -18,22 +133,22 @@
     return $this->isNegative;
   }
 
-  public function setDays($days) {
-    $this->days = $days;
+  public function setWeeks($weeks) {
+    $this->weeks = $weeks;
     return $this;
   }
 
-  public function getDays() {
-    return $this->days;
+  public function getWeeks() {
+    return $this->weeks;
   }
 
-  public function setWeeks($weeks) {
-    $this->weeks = $weeks;
+  public function setDays($days) {
+    $this->days = $days;
     return $this;
   }
 
-  public function getWeeks() {
-    return $this->weeks;
+  public function getDays() {
+    return $this->days;
   }
 
   public function setHours($hours) {
diff --git a/src/parser/calendar/data/PhutilCalendarProxyDateTime.php b/src/parser/calendar/data/PhutilCalendarProxyDateTime.php
--- a/src/parser/calendar/data/PhutilCalendarProxyDateTime.php
+++ b/src/parser/calendar/data/PhutilCalendarProxyDateTime.php
@@ -40,4 +40,8 @@
     return $this->getProxy()->newPHPDateTime();
   }
 
+  public function getTimezone() {
+    return $this->getProxy()->getTimezone();
+  }
+
 }
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
@@ -703,6 +703,7 @@
         }
 
         $result = id(new PhutilCalendarAbsoluteDateTime())
+          ->setTimezone($this->getStartDateTime()->getTimezone())
           ->setViewerTimezone($this->getViewerTimezone())
           ->setYear($this->stateYear)
           ->setMonth($this->stateMonth)
diff --git a/src/parser/calendar/ics/PhutilICSParser.php b/src/parser/calendar/ics/PhutilICSParser.php
--- a/src/parser/calendar/ics/PhutilICSParser.php
+++ b/src/parser/calendar/ics/PhutilICSParser.php
@@ -746,40 +746,16 @@
 
     $value = head($value);
 
-    $pattern =
-      '/^'.
-      '(?P<sign>[+-])?'.
-      'P'.
-      '(?:'.
-        '(?P<W>\d+)W'.
-        '|'.
-        '(?:(?:(?P<D>\d+)D)?'.
-          '(?:T(?:(?P<H>\d+)H)?(?:(?P<M>\d+)M)?(?:(?P<S>\d+)S)?)?'.
-        ')'.
-      ')'.
-      '\z/';
-
-    $matches = null;
-    $ok = preg_match($pattern, $value, $matches);
-    if (!$ok) {
+    try {
+      $duration = PhutilCalendarDuration::newFromISO8601($value);
+    } catch (Exception $ex) {
       $this->raiseParseFailure(
         self::PARSE_BAD_DURATION,
         pht(
-          'Expected DURATION in the format "P12DT3H4M5S", found '.
-          '"%s".',
-          $value));
+          'Invalid DURATION: %s',
+          $ex->getMessage()));
     }
 
-    $is_negative = (idx($matches, 'sign') == '-');
-
-    $duration = id(new PhutilCalendarDuration())
-      ->setIsNegative($is_negative)
-      ->setWeeks((int)idx($matches, 'W', 0))
-      ->setDays((int)idx($matches, 'D', 0))
-      ->setHours((int)idx($matches, 'H', 0))
-      ->setMinutes((int)idx($matches, 'M', 0))
-      ->setSeconds((int)idx($matches, 'S', 0));
-
     return $duration;
   }