diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php
--- a/src/__phutil_library_map__.php
+++ b/src/__phutil_library_map__.php
@@ -132,11 +132,16 @@
     'PhutilCIDRBlock' => 'ip/PhutilCIDRBlock.php',
     'PhutilCIDRList' => 'ip/PhutilCIDRList.php',
     'PhutilCLikeCodeSnippetContextFreeGrammar' => 'grammar/code/PhutilCLikeCodeSnippetContextFreeGrammar.php',
+    'PhutilCalendarAbsoluteDateTime' => 'parser/calendar/data/PhutilCalendarAbsoluteDateTime.php',
     'PhutilCalendarContainerNode' => 'parser/calendar/data/PhutilCalendarContainerNode.php',
+    'PhutilCalendarDateTime' => 'parser/calendar/data/PhutilCalendarDateTime.php',
     'PhutilCalendarDocumentNode' => 'parser/calendar/data/PhutilCalendarDocumentNode.php',
+    'PhutilCalendarDuration' => 'parser/calendar/data/PhutilCalendarDuration.php',
     'PhutilCalendarEventNode' => 'parser/calendar/data/PhutilCalendarEventNode.php',
     'PhutilCalendarNode' => 'parser/calendar/data/PhutilCalendarNode.php',
+    'PhutilCalendarProxyDateTime' => 'parser/calendar/data/PhutilCalendarProxyDateTime.php',
     'PhutilCalendarRawNode' => 'parser/calendar/data/PhutilCalendarRawNode.php',
+    'PhutilCalendarRelativeDateTime' => 'parser/calendar/data/PhutilCalendarRelativeDateTime.php',
     'PhutilCalendarRootNode' => 'parser/calendar/data/PhutilCalendarRootNode.php',
     'PhutilCallbackFilterIterator' => 'utils/PhutilCallbackFilterIterator.php',
     'PhutilCallbackSignalHandler' => 'future/exec/PhutilCallbackSignalHandler.php',
@@ -708,11 +713,16 @@
     'PhutilCIDRBlock' => 'Phobject',
     'PhutilCIDRList' => 'Phobject',
     'PhutilCLikeCodeSnippetContextFreeGrammar' => 'PhutilCodeSnippetContextFreeGrammar',
+    'PhutilCalendarAbsoluteDateTime' => 'PhutilCalendarDateTime',
     'PhutilCalendarContainerNode' => 'PhutilCalendarNode',
+    'PhutilCalendarDateTime' => 'Phobject',
     'PhutilCalendarDocumentNode' => 'PhutilCalendarContainerNode',
+    'PhutilCalendarDuration' => 'Phobject',
     'PhutilCalendarEventNode' => 'PhutilCalendarNode',
     'PhutilCalendarNode' => 'Phobject',
+    'PhutilCalendarProxyDateTime' => 'PhutilCalendarDateTime',
     'PhutilCalendarRawNode' => 'PhutilCalendarContainerNode',
+    'PhutilCalendarRelativeDateTime' => 'PhutilCalendarProxyDateTime',
     'PhutilCalendarRootNode' => 'PhutilCalendarContainerNode',
     'PhutilCallbackFilterIterator' => 'FilterIterator',
     'PhutilCallbackSignalHandler' => 'PhutilSignalHandler',
diff --git a/src/parser/calendar/data/PhutilCalendarAbsoluteDateTime.php b/src/parser/calendar/data/PhutilCalendarAbsoluteDateTime.php
new file mode 100644
--- /dev/null
+++ b/src/parser/calendar/data/PhutilCalendarAbsoluteDateTime.php
@@ -0,0 +1,115 @@
+<?php
+
+final class PhutilCalendarAbsoluteDateTime
+  extends PhutilCalendarDateTime {
+
+  private $year;
+  private $month;
+  private $day;
+  private $hour = 0;
+  private $minute = 0;
+  private $second = 0;
+  private $timezone;
+  private $viewerTimezone;
+
+  public function setYear($year) {
+    $this->year = $year;
+    return $this;
+  }
+
+  public function getYear() {
+    return $this->year;
+  }
+
+  public function setMonth($month) {
+    $this->month = $month;
+    return $this;
+  }
+
+  public function getMonth() {
+    return $this->month;
+  }
+
+  public function setDay($day) {
+    $this->day = $day;
+    return $this;
+  }
+
+  public function getDay() {
+    return $this->day;
+  }
+
+  public function setHour($hour) {
+    $this->hour = $hour;
+    return $this;
+  }
+
+  public function getHour() {
+    return $this->hour;
+  }
+
+  public function setMinute($minute) {
+    $this->minute = $minute;
+    return $this;
+  }
+
+  public function getMinute() {
+    return $this->minute;
+  }
+
+  public function setSecond($second) {
+    $this->second = $second;
+    return $this;
+  }
+
+  public function getSecond() {
+    return $this->second;
+  }
+
+  public function setTimezone($timezone) {
+    $this->timezone = $timezone;
+    return $this;
+  }
+
+  public function getTimezone() {
+    return $this->timezone;
+  }
+
+  private function getEffectiveTimezone() {
+    $zone = $this->getTimezone();
+    if ($zone !== null) {
+      return $zone;
+    }
+
+    $zone = $this->getViewerTimezone();
+    if ($zone !== null) {
+      return $zone;
+    }
+
+    throw new Exception(
+      pht(
+        'Datetime has no timezone or viewer timezone.'));
+  }
+
+  protected function newPHPDateTimeZone() {
+    $zone = $this->getEffectiveTimezone();
+    return new DateTimeZone($zone);
+  }
+
+  protected function newPHPDateTime() {
+    $zone = $this->newPHPDateTimeZone();
+
+    $y = $this->getYear();
+    $m = $this->getMonth();
+    $d = $this->getDay();
+
+    $h = $this->getHour();
+    $i = $this->getMinute();
+    $s = $this->getSecond();
+
+    $format = sprintf('%04d-%02d-%02d %02d:%02d:%02d', $y, $m, $d, $h, $i, $s);
+
+    return new DateTime($format, $zone);
+  }
+
+}
diff --git a/src/parser/calendar/data/PhutilCalendarDateTime.php b/src/parser/calendar/data/PhutilCalendarDateTime.php
new file mode 100644
--- /dev/null
+++ b/src/parser/calendar/data/PhutilCalendarDateTime.php
@@ -0,0 +1,25 @@
+<?php
+
+abstract class PhutilCalendarDateTime
+  extends Phobject {
+
+  private $viewerTimezone;
+
+  public function setViewerTimezone($viewer_timezone) {
+    $this->viewerTimezone = $viewer_timezone;
+    return $this;
+  }
+
+  public function getViewerTimezone() {
+    return $this->viewerTimezone;
+  }
+
+  public function getEpoch() {
+    $datetime = $this->newPHPDateTime();
+    return (int)$datetime->format('U');
+  }
+
+  abstract protected function newPHPDateTimeZone();
+  abstract protected function newPHPDateTime();
+
+}
diff --git a/src/parser/calendar/data/PhutilCalendarDuration.php b/src/parser/calendar/data/PhutilCalendarDuration.php
new file mode 100644
--- /dev/null
+++ b/src/parser/calendar/data/PhutilCalendarDuration.php
@@ -0,0 +1,66 @@
+<?php
+
+final class PhutilCalendarDuration extends Phobject {
+
+  private $isNegative = false;
+  private $days = 0;
+  private $weeks = 0;
+  private $hours = 0;
+  private $minutes = 0;
+  private $seconds = 0;
+
+  public function setIsNegative($is_negative) {
+    $this->isNegative = $is_negative;
+    return $this;
+  }
+
+  public function getIsNegative() {
+    return $this->isNegative;
+  }
+
+  public function setDays($days) {
+    $this->days = $days;
+    return $this;
+  }
+
+  public function getDays() {
+    return $this->days;
+  }
+
+  public function setWeeks($weeks) {
+    $this->weeks = $weeks;
+    return $this;
+  }
+
+  public function getWeeks() {
+    return $this->weeks;
+  }
+
+  public function setHours($hours) {
+    $this->hours = $hours;
+    return $this;
+  }
+
+  public function getHours() {
+    return $this->hours;
+  }
+
+  public function setMinutes($minutes) {
+    $this->minutes = $minutes;
+    return $this;
+  }
+
+  public function getMinutes() {
+    return $this->minutes;
+  }
+
+  public function setSeconds($seconds) {
+    $this->seconds = $seconds;
+    return $this;
+  }
+
+  public function getSeconds() {
+    return $this->seconds;
+  }
+
+}
diff --git a/src/parser/calendar/data/PhutilCalendarEventNode.php b/src/parser/calendar/data/PhutilCalendarEventNode.php
--- a/src/parser/calendar/data/PhutilCalendarEventNode.php
+++ b/src/parser/calendar/data/PhutilCalendarEventNode.php
@@ -5,4 +5,69 @@
 
   const NODETYPE = 'event';
 
+  private $name;
+  private $description;
+  private $startDateTime;
+  private $endDateTime;
+  private $duration;
+
+  public function setName($name) {
+    $this->name = $name;
+    return $this;
+  }
+
+  public function getName() {
+    return $this->name;
+  }
+
+  public function setDescription($description) {
+    $this->description = $description;
+    return $this;
+  }
+
+  public function getDescription() {
+    return $this->description;
+  }
+
+  public function setStartDateTime(PhutilCalendarDateTime $start_date_time) {
+    $this->startDateTime = $start_date_time;
+    return $this;
+  }
+
+  public function getStartDateTime() {
+    return $this->startDateTime;
+  }
+
+  public function setEndDateTime(PhutilCalendarDateTime $end_date_time) {
+    $this->endDateTime = $end_date_time;
+    return $this;
+  }
+
+  public function getEndDateTime() {
+    $end = $this->endDateTime;
+    if ($end) {
+      return $end;
+    }
+
+    $start = $this->getStartDateTime();
+    $duration = $this->getDuration();
+    if ($start && $duration) {
+      return id(new PhutilCalendarRelativeDateTime())
+        ->setOrigin($start)
+        ->setDuration($duration);
+    }
+
+    return null;
+  }
+
+  public function setDuration(PhutilCalendarDuration $duration) {
+    $this->duration = $duration;
+    return $this;
+  }
+
+  public function getDuration() {
+    return $this->duration;
+  }
+
+
 }
diff --git a/src/parser/calendar/data/PhutilCalendarProxyDateTime.php b/src/parser/calendar/data/PhutilCalendarProxyDateTime.php
new file mode 100644
--- /dev/null
+++ b/src/parser/calendar/data/PhutilCalendarProxyDateTime.php
@@ -0,0 +1,34 @@
+<?php
+
+abstract class PhutilCalendarProxyDateTime
+  extends PhutilCalendarDateTime {
+
+  private $proxy;
+
+  final protected function setProxy(PhutilCalendarDateTime $proxy) {
+    $this->proxy = $proxy;
+    return $this;
+  }
+
+  final protected function getProxy() {
+    return $this->proxy;
+  }
+
+  public function setViewerTimezone($timezone) {
+    $this->getProxy()->setViewerTimezone($timezone);
+    return $this;
+  }
+
+  public function getViewerTimezone() {
+    return $this->getProxy()->getViewerTimezone();
+  }
+
+  protected function newPHPDateTimezone() {
+    return $this->getProxy()->newPHPDateTimezone();
+  }
+
+  protected function newPHPDateTime() {
+    return $this->getProxy()->newPHPDateTime();
+  }
+
+}
diff --git a/src/parser/calendar/data/PhutilCalendarRelativeDateTime.php b/src/parser/calendar/data/PhutilCalendarRelativeDateTime.php
new file mode 100644
--- /dev/null
+++ b/src/parser/calendar/data/PhutilCalendarRelativeDateTime.php
@@ -0,0 +1,53 @@
+<?php
+
+final class PhutilCalendarRelativeDateTime
+  extends PhutilCalendarProxyDateTime {
+
+  private $duration;
+
+  public function setOrigin(PhutilCalendarDateTime $origin) {
+    return $this->setProxy($origin);
+  }
+
+  public function getOrigin() {
+    return $this->getProxy();
+  }
+
+  public function setDuration(PhutilCalendarDuration $duration) {
+    $this->duration = $duration;
+    return $this;
+  }
+
+  public function getDuration() {
+    return $this->duration;
+  }
+
+  protected function newPHPDateTime() {
+    $datetime = parent::newPHPDateTime();
+    $duration = $this->getDuration();
+
+    if ($duration->getIsNegative()) {
+      $sign = '-';
+    } else {
+      $sign = '+';
+    }
+
+    $map = array(
+      'weeks' => $duration->getWeeks(),
+      'days' => $duration->getDays(),
+      'hours' => $duration->getHours(),
+      'minutes' => $duration->getMinutes(),
+      'seconds' => $duration->getSeconds(),
+    );
+
+    foreach ($map as $unit => $value) {
+      if (!$value) {
+        continue;
+      }
+      $datetime->modify("{$sign}{$value} {$unit}");
+    }
+
+    return $datetime;
+  }
+
+}
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
@@ -8,6 +8,8 @@
   private $lines;
   private $cursor;
 
+  private $warnings;
+
   const PARSE_MISSING_END = 'missing-end';
   const PARSE_INITIAL_UNFOLD = 'initial-unfold';
   const PARSE_UNEXPECTED_CHILD = 'unexpected-child';
@@ -22,11 +24,22 @@
   const PARSE_MALFORMED_PROPERTY = 'malformed-property';
   const PARSE_MISSING_VALUE = 'missing-value';
   const PARSE_UNESCAPED_BACKSLASH = 'unescaped-backslash';
+  const PARSE_MULTIPLE_PARAMETERS = 'multiple-parameters';
+  const PARSE_EMPTY_DATETIME = 'empty-datetime';
+  const PARSE_MANY_DATETIME = 'many-datetime';
+  const PARSE_BAD_DATETIME = 'bad-datetime';
+  const PARSE_BAD_TZID = 'bad-tzid';
+  const PARSE_EMPTY_DURATION = 'empty-duration';
+  const PARSE_MANY_DURATION = 'many-duration';
+  const PARSE_BAD_DURATION = 'bad-duration';
+
+  const WARN_TZID_UTC = 'warn-tzid-utc';
 
   public function parseICSData($data) {
     $this->stack = array();
     $this->node = null;
     $this->cursor = null;
+    $this->warnings = array();
 
     $lines = $this->unfoldICSLines($data);
     $this->lines = $lines;
@@ -302,6 +315,7 @@
 
     $node = $this->getNode();
 
+
     $raw = $node->getAttribute('ics.properties', array());
     $raw[] = array(
       'name' => $name,
@@ -309,6 +323,12 @@
       'value' => $value,
     );
     $node->setAttribute('ics.properties', $raw);
+
+    switch ($node->getAttribute('ics.type')) {
+      case 'VEVENT':
+        $this->didParseEventProperty($node, $name, $parameters, $value);
+        break;
+    }
   }
 
   private function unescapeParameterValue($data) {
@@ -465,10 +485,18 @@
         $result = explode(',', $data);
         break;
       case 'DATE-TIME':
-        $result = explode(',', $data);
+        if (!strlen($data)) {
+          $result = array();
+        } else {
+          $result = explode(',', $data);
+        }
         break;
       case 'DURATION':
-        $result = explode(',', $data);
+        if (!strlen($data)) {
+          $result = array();
+        } else {
+          $result = explode(',', $data);
+        }
         break;
       case 'FLOAT':
         $result = explode(',', $data);
@@ -572,4 +600,227 @@
       ->setParserFailureCode($code);
   }
 
+  private function raiseWarning($code, $message) {
+    $this->warnings[] = array(
+      'code' => $code,
+      'line' => $this->cursor,
+      'text' => $this->lines[$this->cursor],
+      'message' => $message,
+    );
+
+    return $this;
+  }
+
+  private function didParseEventProperty(
+    PhutilCalendarEventNode $node,
+    $name,
+    array $parameters,
+    array $value) {
+
+    switch ($name) {
+      case 'SUMMARY':
+        $text = $this->newTextFromProperty($parameters, $value);
+        $node->setName($text);
+        break;
+      case 'DESCRIPTION':
+        $text = $this->newTextFromProperty($parameters, $value);
+        $node->setDescription($text);
+        break;
+      case 'DTSTART':
+        $datetime = $this->newDateTimeFromProperty($parameters, $value);
+        $node->setStartDateTime($datetime);
+        break;
+      case 'DTEND':
+        $datetime = $this->newDateTimeFromProperty($parameters, $value);
+        $node->setEndDateTime($datetime);
+        break;
+      case 'DURATION':
+        $duration = $this->newDurationFromProperty($parameters, $value);
+        $node->setDuration($duration);
+        break;
+    }
+
+  }
+
+  private function newTextFromProperty(array $parameters, array $value) {
+    $value = $value['value'];
+    return implode("\n\n", $value);
+  }
+
+  private function newDateTimeFromProperty(array $parameters, array $value) {
+    $value = $value['value'];
+
+    if (!$value) {
+      $this->raiseParseFailure(
+        self::PARSE_EMPTY_DATETIME,
+        pht(
+          'Expected DATE-TIME to have exactly one value, found none.'));
+
+    }
+
+    if (count($value) > 1) {
+      $this->raiseParseFailure(
+        self::PARSE_MANY_DATETIME,
+        pht(
+          'Expected DATE-TIME to have exactly one value, found more than '.
+          'one.'));
+    }
+
+    $value = head($value);
+
+    $pattern =
+      '/^'.
+      '(?P<y>\d{4})(?P<m>\d{2})(?P<d>\d{2})'.
+      '(?:'.
+        'T(?P<h>\d{2})(?P<i>\d{2})(?P<s>\d{2})(?<z>Z)?'.
+      ')?'.
+      '\z/';
+
+    $matches = null;
+    $ok = preg_match($pattern, $value, $matches);
+    if (!$ok) {
+      $this->raiseParseFailure(
+        self::PARSE_BAD_DATETIME,
+        pht(
+          'Expected DATE-TIME in the format "19990105T112233Z", found '.
+          '"%s".',
+          $value));
+    }
+
+    $tzid = $this->getScalarParameterValue($parameters, 'TZID');
+
+    if (isset($matches['z'])) {
+      if ($tzid) {
+        $this->raiseWarning(
+          self::WARN_TZID_UTC,
+          pht(
+            'DATE-TIME "%s" uses "Z" to specify UTC, but also has a TZID '.
+            'parameter with value "%s". This violates RFC5545. The TZID '.
+            'will be ignored, and the value will be interpreted as UTC.',
+            $value,
+            $tzid));
+      }
+      $tzid = 'UTC';
+    } else if ($tzid !== null) {
+      $map = DateTimeZone::listIdentifiers();
+      $map = array_fuse($map);
+      if (empty($map[$tzid])) {
+        $this->raiseParseFailure(
+          self::PARSE_BAD_TZID,
+          pht(
+            'Timezone "%s" is not a recognized timezone.',
+            $tzid));
+      }
+    }
+
+    $datetime = id(new PhutilCalendarAbsoluteDateTime())
+      ->setYear((int)$matches['y'])
+      ->setMonth((int)$matches['m'])
+      ->setDay((int)$matches['d'])
+      ->setTimezone($tzid);
+
+    if (isset($matches['h'])) {
+      $datetime
+        ->setHour((int)$matches['h'])
+        ->setMinute((int)$matches['i'])
+        ->setSecond((int)$matches['s']);
+    }
+
+    return $datetime;
+  }
+
+  private function newDurationFromProperty(array $parameters, array $value) {
+    $value = $value['value'];
+
+    if (!$value) {
+      $this->raiseParseFailure(
+        self::PARSE_EMPTY_DURATION,
+        pht(
+          'Expected DURATION to have exactly one value, found none.'));
+
+    }
+
+    if (count($value) > 1) {
+      $this->raiseParseFailure(
+        self::PARSE_MANY_DURATION,
+        pht(
+          'Expected DURATION to have exactly one value, found more than '.
+          'one.'));
+    }
+
+    $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) {
+      $this->raiseParseFailure(
+        self::PARSE_BAD_DURATION,
+        pht(
+          'Expected DURATION in the format "P12DT3H4M5S", found '.
+          '"%s".',
+          $value));
+    }
+
+    $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;
+  }
+
+  private function getScalarParameterValue(
+    array $parameters,
+    $name,
+    $default = null) {
+
+    $match = null;
+    foreach ($parameters as $parameter) {
+      if ($parameter['name'] == $name) {
+        $match = $parameter;
+      }
+    }
+
+    if ($match === null) {
+      return $default;
+    }
+
+    $value = $match['values'];
+    if (!$value) {
+      // Parameter is specified, but with no value, like "KEY=". Just return
+      // the default, as though the parameter was not specified.
+      return $default;
+    }
+
+    if (count($value) > 1) {
+      $this->raiseParseFailure(
+        self::PARSE_MULTIPLE_PARAMETERS,
+        pht(
+          'Expected parameter "%s" to have at most one value, but found '.
+          'more than one.',
+          $name));
+    }
+
+    return idx(head($value), 'value');
+  }
+
+
 }
diff --git a/src/parser/calendar/ics/__tests__/PhutilICSParserTestCase.php b/src/parser/calendar/ics/__tests__/PhutilICSParserTestCase.php
--- a/src/parser/calendar/ics/__tests__/PhutilICSParserTestCase.php
+++ b/src/parser/calendar/ics/__tests__/PhutilICSParserTestCase.php
@@ -3,16 +3,8 @@
 final class PhutilICSParserTestCase extends PhutilTestCase {
 
   public function testICSParser() {
-    $root = $this->parseICSDocument('simple.ics');
+    $event = $this->parseICSSingleEvent('simple.ics');
 
-    $documents = $root->getDocuments();
-    $this->assertEqual(1, count($documents));
-    $document = head($documents);
-
-    $events = $document->getEvents();
-    $this->assertEqual(1, count($events));
-
-    $event = head($events);
     $this->assertEqual(
       array(
         array(
@@ -38,6 +30,27 @@
           ),
         ),
         array(
+          'name' => 'DTSTART',
+          'parameters' => array(
+            array(
+              'name' => 'TZID',
+              'values' => array(
+                array(
+                  'value' => 'America/Los_Angeles',
+                  'quoted' => false,
+                ),
+              ),
+            ),
+          ),
+          'value' => array(
+            'type' => 'DATE-TIME',
+            'value' => array(
+              '20160915T090000',
+            ),
+            'raw' => '20160915T090000',
+          ),
+        ),
+        array(
           'name' => 'DTEND',
           'parameters' => array(
             array(
@@ -64,13 +77,119 @@
           'value' => array(
             'type' => 'TEXT',
             'value' => array(
-              'Example Event',
+              'Simple Event',
+            ),
+            'raw' => 'Simple Event',
+          ),
+        ),
+        array(
+          'name' => 'DESCRIPTION',
+          'parameters' => array(),
+          'value' => array(
+            'type' => 'TEXT',
+            'value' => array(
+              'This is a simple event.',
             ),
-            'raw' => 'Example Event',
+            'raw' => 'This is a simple event.',
           ),
         ),
       ),
       $event->getAttribute('ics.properties'));
+
+    $this->assertEqual(
+      'Simple Event',
+      $event->getName());
+
+    $this->assertEqual(
+      'This is a simple event.',
+      $event->getDescription());
+
+    $this->assertEqual(
+      1473955200,
+      $event->getStartDateTime()->getEpoch());
+
+    $this->assertEqual(
+      1473955200 + phutil_units('1 hour in seconds'),
+      $event->getEndDateTime()->getEpoch());
+  }
+
+  public function testICSFloatingTime() {
+    // This tests "floating" event times, which have no absolute time and are
+    // supposed to be interpreted using the viewer's timezone. It also uses
+    // a duration, and the duration needs to float along with the viewer
+    // timezone.
+
+    $event = $this->parseICSSingleEvent('floating.ics');
+
+    $start = $event->getStartDateTime();
+
+    $caught = null;
+    try {
+      $start->getEpoch();
+    } catch (Exception $ex) {
+      $caught = $ex;
+    }
+
+    $this->assertTrue(
+      ($caught instanceof Exception),
+      pht('Expected exception for floating time with no viewer timezone.'));
+
+    $newyears_utc = strtotime('2015-01-01 00:00:00 UTC');
+    $this->assertEqual(1420070400, $newyears_utc);
+
+    $start->setViewerTimezone('UTC');
+    $this->assertEqual(
+      $newyears_utc,
+      $start->getEpoch());
+
+    $start->setViewerTimezone('America/Los_Angeles');
+    $this->assertEqual(
+      $newyears_utc + phutil_units('8 hours in seconds'),
+      $start->getEpoch());
+
+    $start->setViewerTimezone('America/New_York');
+    $this->assertEqual(
+      $newyears_utc + phutil_units('5 hours in seconds'),
+      $start->getEpoch());
+
+    $end = $event->getEndDateTime();
+    $end->setViewerTimezone('UTC');
+    $this->assertEqual(
+      $newyears_utc + phutil_units('24 hours in seconds'),
+      $end->getEpoch());
+
+    $end->setViewerTimezone('America/Los_Angeles');
+    $this->assertEqual(
+      $newyears_utc + phutil_units('32 hours in seconds'),
+      $end->getEpoch());
+
+    $end->setViewerTimezone('America/New_York');
+    $this->assertEqual(
+      $newyears_utc + phutil_units('29 hours in seconds'),
+      $end->getEpoch());
+  }
+
+  public function testICSDuration() {
+    $event = $this->parseICSSingleEvent('duration.ics');
+
+    // Raw value is "20160719T095722Z".
+    $start_epoch = strtotime('2016-07-19 09:57:22 UTC');
+    $this->assertEqual(1468922242, $start_epoch);
+
+    // Raw value is "P1DT17H4M23S".
+    $duration =
+      phutil_units('1 day in seconds') +
+      phutil_units('17 hours in seconds') +
+      phutil_units('4 minutes in seconds') +
+      phutil_units('23 seconds in seconds');
+
+    $this->assertEqual(
+      $start_epoch,
+      $event->getStartDateTime()->getEpoch());
+
+    $this->assertEqual(
+      $start_epoch + $duration,
+      $event->getEndDateTime()->getEpoch());
   }
 
   public function testICSParserErrors() {
@@ -94,6 +213,22 @@
         PhutilICSParser::PARSE_UNESCAPED_BACKSLASH,
       'err-unexpected-child.ics' => PhutilICSParser::PARSE_UNEXPECTED_CHILD,
       'err-unexpected-text.ics' => PhutilICSParser::PARSE_UNEXPECTED_TEXT,
+      'err-multiple-parameters.ics' =>
+        PhutilICSParser::PARSE_MULTIPLE_PARAMETERS,
+      'err-empty-datetime.ics' =>
+        PhutilICSParser::PARSE_EMPTY_DATETIME,
+      'err-many-datetime.ics' =>
+        PhutilICSParser::PARSE_MANY_DATETIME,
+      'err-bad-datetime.ics' =>
+        PhutilICSParser::PARSE_BAD_DATETIME,
+      'err-bad-tzid.ics' =>
+        PhutilICSParser::PARSE_BAD_TZID,
+      'err-empty-duration.ics' =>
+        PhutilICSParser::PARSE_EMPTY_DURATION,
+      'err-many-duration.ics' =>
+        PhutilICSParser::PARSE_MANY_DURATION,
+      'err-bad-duration.ics' =>
+        PhutilICSParser::PARSE_BAD_DURATION,
 
       'simple.ics' => null,
       'good-boolean.ics' => null,
@@ -135,6 +270,19 @@
     }
   }
 
+  private function parseICSSingleEvent($name) {
+    $root = $this->parseICSDocument($name);
+
+    $documents = $root->getDocuments();
+    $this->assertEqual(1, count($documents));
+    $document = head($documents);
+
+    $events = $document->getEvents();
+    $this->assertEqual(1, count($events));
+
+    return head($events);
+  }
+
   private function parseICSDocument($name) {
     $path = dirname(__FILE__).'/data/'.$name;
     $data = Filesystem::readFile($path);
diff --git a/src/parser/calendar/ics/__tests__/data/duration.ics b/src/parser/calendar/ics/__tests__/data/duration.ics
new file mode 100644
--- /dev/null
+++ b/src/parser/calendar/ics/__tests__/data/duration.ics
@@ -0,0 +1,8 @@
+BEGIN:VCALENDAR
+BEGIN:VEVENT
+DTSTART:20160719T095722Z
+DURATION:P1DT17H4M23S
+SUMMARY:Duration Event
+DESCRIPTION:This is an event with a complex duration.
+END:VEVENT
+END:VCALENDAR
diff --git a/src/parser/calendar/ics/__tests__/data/err-bad-datetime.ics b/src/parser/calendar/ics/__tests__/data/err-bad-datetime.ics
new file mode 100644
--- /dev/null
+++ b/src/parser/calendar/ics/__tests__/data/err-bad-datetime.ics
@@ -0,0 +1,5 @@
+BEGIN:VCALENDAR
+BEGIN:VEVENT
+DTSTART:quack
+END:VEVENT
+END:VCALENDAR
diff --git a/src/parser/calendar/ics/__tests__/data/err-bad-duration.ics b/src/parser/calendar/ics/__tests__/data/err-bad-duration.ics
new file mode 100644
--- /dev/null
+++ b/src/parser/calendar/ics/__tests__/data/err-bad-duration.ics
@@ -0,0 +1,5 @@
+BEGIN:VCALENDAR
+BEGIN:VEVENT
+DURATION:quack
+END:VEVENT
+END:VCALENDAR
diff --git a/src/parser/calendar/ics/__tests__/data/err-bad-tzid.ics b/src/parser/calendar/ics/__tests__/data/err-bad-tzid.ics
new file mode 100644
--- /dev/null
+++ b/src/parser/calendar/ics/__tests__/data/err-bad-tzid.ics
@@ -0,0 +1,5 @@
+BEGIN:VCALENDAR
+BEGIN:VEVENT
+DTSTART;TZID=quack:20130101
+END:VEVENT
+END:VCALENDAR
diff --git a/src/parser/calendar/ics/__tests__/data/err-empty-datetime.ics b/src/parser/calendar/ics/__tests__/data/err-empty-datetime.ics
new file mode 100644
--- /dev/null
+++ b/src/parser/calendar/ics/__tests__/data/err-empty-datetime.ics
@@ -0,0 +1,5 @@
+BEGIN:VCALENDAR
+BEGIN:VEVENT
+DTSTART:
+END:VEVENT
+END:VCALENDAR
diff --git a/src/parser/calendar/ics/__tests__/data/err-empty-duration.ics b/src/parser/calendar/ics/__tests__/data/err-empty-duration.ics
new file mode 100644
--- /dev/null
+++ b/src/parser/calendar/ics/__tests__/data/err-empty-duration.ics
@@ -0,0 +1,5 @@
+BEGIN:VCALENDAR
+BEGIN:VEVENT
+DURATION:
+END:VEVENT
+END:VCALENDAR
diff --git a/src/parser/calendar/ics/__tests__/data/err-many-datetime.ics b/src/parser/calendar/ics/__tests__/data/err-many-datetime.ics
new file mode 100644
--- /dev/null
+++ b/src/parser/calendar/ics/__tests__/data/err-many-datetime.ics
@@ -0,0 +1,5 @@
+BEGIN:VCALENDAR
+BEGIN:VEVENT
+DTSTART:20130101,20130101
+END:VEVENT
+END:VCALENDAR
diff --git a/src/parser/calendar/ics/__tests__/data/err-many-duration.ics b/src/parser/calendar/ics/__tests__/data/err-many-duration.ics
new file mode 100644
--- /dev/null
+++ b/src/parser/calendar/ics/__tests__/data/err-many-duration.ics
@@ -0,0 +1,5 @@
+BEGIN:VCALENDAR
+BEGIN:VEVENT
+DURATION:P1W,P2W
+END:VEVENT
+END:VCALENDAR
diff --git a/src/parser/calendar/ics/__tests__/data/err-multiple-parameters.ics b/src/parser/calendar/ics/__tests__/data/err-multiple-parameters.ics
new file mode 100644
--- /dev/null
+++ b/src/parser/calendar/ics/__tests__/data/err-multiple-parameters.ics
@@ -0,0 +1,5 @@
+BEGIN:VCALENDAR
+BEGIN:VEVENT
+DTSTART;TZID=A,B:20160915T090000
+END:VEVENT
+END:VCALENDAR
diff --git a/src/parser/calendar/ics/__tests__/data/floating.ics b/src/parser/calendar/ics/__tests__/data/floating.ics
new file mode 100644
--- /dev/null
+++ b/src/parser/calendar/ics/__tests__/data/floating.ics
@@ -0,0 +1,8 @@
+BEGIN:VCALENDAR
+BEGIN:VEVENT
+DTSTART:20150101T000000
+DURATION:P1D
+SUMMARY:New Year's 2015
+DESCRIPTION:This is an event with a floating start time.
+END:VEVENT
+END:VCALENDAR
diff --git a/src/parser/calendar/ics/__tests__/data/simple.ics b/src/parser/calendar/ics/__tests__/data/simple.ics
--- a/src/parser/calendar/ics/__tests__/data/simple.ics
+++ b/src/parser/calendar/ics/__tests__/data/simple.ics
@@ -4,7 +4,9 @@
 BEGIN:VEVENT
 CREATED:20160908T172702Z
 UID:1CEB57AF-0C9C-402D-B3BD-D75BD4843F68
+DTSTART;TZID=America/Los_Angeles:20160915T090000
 DTEND;TZID=America/Los_Angeles:20160915T100000
-SUMMARY:Example Event
+SUMMARY:Simple Event
+DESCRIPTION:This is a simple event.
 END:VEVENT
 END:VCALENDAR