diff --git a/config/install/datetime_extras.date_range_format.medium.yml b/config/install/datetime_extras.date_range_format.medium.yml
new file mode 100644
index 0000000..073e0b2
--- /dev/null
+++ b/config/install/datetime_extras.date_range_format.medium.yml
@@ -0,0 +1,17 @@
+langcode: en
+status: true
+dependencies: {  }
+id: medium
+label: Medium
+date_settings:
+  default_pattern: 'j F Y'
+  separator: '–'
+  same_month_start_pattern: j
+  same_month_end_pattern: 'j F Y'
+  same_year_start_pattern: 'j F'
+  same_year_end_pattern: 'j F Y'
+datetime_settings:
+  default_pattern: 'j F Y H:i'
+  separator: '–'
+  same_day_start_pattern: 'j F Y H:i'
+  same_day_end_pattern: 'H:i'
diff --git a/config/schema/datetime_extras.schema.yml b/config/schema/datetime_extras.schema.yml
new file mode 100644
index 0000000..d9dd2e5
--- /dev/null
+++ b/config/schema/datetime_extras.schema.yml
@@ -0,0 +1,58 @@
+datetime_extras.date_range_format.*:
+  type: config_entity
+  label: 'Date range format config'
+  mapping:
+    id:
+      type: string
+      label: 'ID'
+    label:
+      type: label
+      label: 'Label'
+
+    date_settings:
+      type: mapping
+      label: 'Date settings'
+      mapping:
+        default_pattern:
+          type: string
+          label: 'Default pattern'
+        separator:
+          type: string
+          label: 'Separator'
+        same_month_start_pattern:
+          type: string
+          label: 'Same month start pattern'
+        same_month_end_pattern:
+          type: string
+          label: 'Same month end pattern'
+        same_year_start_pattern:
+          type: string
+          label: 'Same year start pattern'
+        same_year_end_pattern:
+          type: string
+          label: 'Same year end pattern'
+
+    datetime_settings:
+      type: mapping
+      label: 'Datetime settings'
+      mapping:
+        default_pattern:
+          type: string
+          label: 'Default pattern'
+        separator:
+          type: string
+          label: 'Separator'
+        same_day_start_pattern:
+          type: string
+          label: 'Same day start pattern'
+        same_day_end_pattern:
+          type: string
+          label: 'Same day end pattern'
+
+field.formatter.settings.daterange_compact:
+  type: mapping
+  label: 'Date/time range compact display format settings'
+  mapping:
+    format_type:
+      type: string
+      label: 'Date/time range format'
diff --git a/datetime_extras.links.action.yml b/datetime_extras.links.action.yml
new file mode 100644
index 0000000..c4b2efa
--- /dev/null
+++ b/datetime_extras.links.action.yml
@@ -0,0 +1,5 @@
+entity.date_range_format.add_form:
+  route_name: 'entity.date_range_format.add_form'
+  title: 'Add format'
+  appears_on:
+    - entity.date_range_format.collection
diff --git a/datetime_extras.links.menu.yml b/datetime_extras.links.menu.yml
new file mode 100644
index 0000000..c01d8e3
--- /dev/null
+++ b/datetime_extras.links.menu.yml
@@ -0,0 +1,6 @@
+entity.date_range_format.collection:
+  title: 'Date and time range formats'
+  route_name: entity.date_range_format.collection
+  description: 'Configure how date and time ranges are displayed.'
+  parent: system.admin_config_regional
+  weight: 0
diff --git a/datetime_extras.services.yml b/datetime_extras.services.yml
new file mode 100644
index 0000000..321b60d
--- /dev/null
+++ b/datetime_extras.services.yml
@@ -0,0 +1,4 @@
+services:
+  datetime_extras.date_range.formatter:
+    class: Drupal\datetime_extras\DateRangeFormatter
+    arguments: ['@entity_type.manager', '@date.formatter']
diff --git a/src/DateRangeFormatListBuilder.php b/src/DateRangeFormatListBuilder.php
new file mode 100644
index 0000000..2fc2a14
--- /dev/null
+++ b/src/DateRangeFormatListBuilder.php
@@ -0,0 +1,151 @@
+<?php
+
+namespace Drupal\datetime_extras;
+
+use Drupal\Core\Config\Entity\ConfigEntityListBuilder;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\datetime_extras\Entity\DateRangeFormatInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides a listing of date range format entities.
+ */
+class DateRangeFormatListBuilder extends ConfigEntityListBuilder {
+
+  /**
+   * The date range formatter service.
+   *
+   * @var \Drupal\datetime_extras\DateRangeFormatterInterface
+   */
+  protected $dateRangeFormatter;
+
+  /**
+   * Constructs a new DateFormatListBuilder object.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   The entity type definition.
+   * @param \Drupal\Core\Entity\EntityStorageInterface $storage
+   *   The entity storage class.
+   * @param \Drupal\datetime_extras\DateRangeFormatterInterface $date_range_formatter
+   *   The date formatter service.
+   */
+  public function __construct(EntityTypeInterface $entity_type, EntityStorageInterface $storage, DateRangeFormatterInterface $date_range_formatter) {
+    parent::__construct($entity_type, $storage);
+    $this->dateRangeFormatter = $date_range_formatter;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
+    return new static(
+      $entity_type,
+      $container->get('entity.manager')->getStorage($entity_type->id()),
+      $container->get('datetime_extras.date_range.formatter')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildHeader() {
+    $header['label'] = $this->t('Name');
+    $header['date'] = $this->t('Date examples');
+    $header['datetime'] = $this->t('Date & time examples');
+    return $header + parent::buildHeader();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildRow(EntityInterface $entity) {
+    /** @var \Drupal\datetime_extras\Entity\DateRangeFormatInterface $format */
+    $format = $entity;
+
+    $row['label'] = $format->label();
+    $row['date']['data'] = $this->dateExamples($format);
+    $row['datetime']['data'] = $this->dateTimeExamples($format);
+    return $row + parent::buildRow($entity);
+  }
+
+  /**
+   * Examples of various date ranges shown using the given format.
+   *
+   * @param \Drupal\datetime_extras\Entity\DateRangeFormatInterface $format
+   *   The date range format entity.
+   *
+   * @return array
+   *   A render array suitable for use within the list builder table.
+   */
+  private function dateExamples(DateRangeFormatInterface $format) {
+    $examples = [];
+
+    // An example range that is a single day.
+    $same_day_timestamp = \DateTime::createFromFormat('Y-m-d', '2017-01-01')->getTimestamp();
+    $examples[] = $this->dateRangeFormatter->formatDateRange(
+      $same_day_timestamp, $same_day_timestamp, $format->id());
+
+    // An example range that spans several days within the same month.
+    $same_month_start_timestamp = \DateTime::createFromFormat('Y-m-d', '2017-01-02')->getTimestamp();
+    $same_month_end_timestamp = \DateTime::createFromFormat('Y-m-d', '2017-01-03')->getTimestamp();
+    $examples[] = $this->dateRangeFormatter->formatDateRange(
+      $same_month_start_timestamp, $same_month_end_timestamp, $format->id());
+
+    // An example range that spans several months within the same year.
+    $same_year_start_timestamp = \DateTime::createFromFormat('Y-m-d', '2017-01-04')->getTimestamp();
+    $same_year_end_timestamp = \DateTime::createFromFormat('Y-m-d', '2017-02-05')->getTimestamp();
+    $examples[] = $this->dateRangeFormatter->formatDateRange(
+      $same_year_start_timestamp, $same_year_end_timestamp, $format->id());
+
+    // An example range that spans multiple years.
+    $fallback_start_timestamp = \DateTime::createFromFormat('Y-m-d', '2017-01-06')->getTimestamp();
+    $fallback_end_timestamp = \DateTime::createFromFormat('Y-m-d', '2018-01-07')->getTimestamp();
+    $examples[] = $this->dateRangeFormatter->formatDateRange(
+      $fallback_start_timestamp, $fallback_end_timestamp, $format->id());
+
+    $output = '';
+    foreach ($examples as $example) {
+      $output .= htmlspecialchars($example) . '<br>';
+    }
+    return ['#markup' => $output];
+  }
+
+  /**
+   * Examples of various datetime ranges shown using the given format.
+   *
+   * @param \Drupal\datetime_extras\Entity\DateRangeFormatInterface $format
+   *   The date range format entity.
+   *
+   * @return array
+   *   A render array suitable for use within the list builder table.
+   */
+  private function dateTimeExamples(DateRangeFormatInterface $format) {
+    $examples = [];
+
+    // An example range that is a single date and time.
+    $same_time_timestamp = \DateTime::createFromFormat('Y-m-d H:i', '2017-01-01 09:00')->getTimestamp();
+    $examples[] = $this->dateRangeFormatter->formatDateTimeRange(
+      $same_time_timestamp, $same_time_timestamp, $format->id());
+
+    // An example range that is contained within a single day.
+    $same_day_start_timestamp = \DateTime::createFromFormat('Y-m-d H:i', '2017-01-01 09:00')->getTimestamp();
+    $same_day_end_timestamp = \DateTime::createFromFormat('Y-m-d H:i', '2017-01-01 13:00')->getTimestamp();
+    $examples[] = $this->dateRangeFormatter->formatDateTimeRange(
+      $same_day_start_timestamp, $same_day_end_timestamp, $format->id());
+
+    // An example range that spans multiple days.
+    $fallback_start_timestamp = \DateTime::createFromFormat('Y-m-d H:i', '2017-01-01 09:00')->getTimestamp();
+    $fallback_end_timestamp = \DateTime::createFromFormat('Y-m-d H:i', '2017-01-02 13:00')->getTimestamp();
+    $examples[] = $this->dateRangeFormatter->formatDateTimeRange(
+      $fallback_start_timestamp, $fallback_end_timestamp, $format->id());
+
+    $output = '';
+    foreach ($examples as $example) {
+      $output .= htmlspecialchars($example) . '<br>';
+    }
+    return ['#markup' => $output];
+  }
+
+}
diff --git a/src/DateRangeFormatter.php b/src/DateRangeFormatter.php
new file mode 100644
index 0000000..b71fb49
--- /dev/null
+++ b/src/DateRangeFormatter.php
@@ -0,0 +1,135 @@
+<?php
+
+namespace Drupal\datetime_extras;
+
+use Drupal\Core\Datetime\DateFormatterInterface;
+use Drupal\Core\Datetime\DrupalDateTime;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+
+/**
+ * Provides a service to handle formatting of date/time ranges.
+ */
+class DateRangeFormatter implements DateRangeFormatterInterface {
+
+  /**
+   * The date range format storage.
+   *
+   * @var \Drupal\Core\Entity\EntityStorageInterface
+   */
+  protected $dateRangeFormatStorage;
+
+  /**
+   * The core date formatter.
+   *
+   * @var \Drupal\Core\Datetime\DateFormatterInterface
+   */
+  protected $dateFormatter;
+
+  /**
+   * Constructs the date range formatter service.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity manager.
+   * @param \Drupal\Core\Datetime\DateFormatterInterface $date_formatter
+   *   The core date formatter.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, DateFormatterInterface $date_formatter) {
+    $this->dateRangeFormatStorage = $entity_type_manager->getStorage('date_range_format');
+    $this->dateFormatter = $date_formatter;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function formatDateRange($start_timestamp, $end_timestamp, $type = 'medium', $timezone = NULL, $langcode = NULL) {
+    $start_date_time = DrupalDateTime::createFromTimestamp($start_timestamp, $timezone);
+    $end_date_time = DrupalDateTime::createFromTimestamp($end_timestamp, $timezone);
+
+    /** @var \Drupal\datetime_extras\Entity\DateRangeFormatInterface $entity */
+    $entity = $this->dateRangeFormatStorage->load($type);
+    $date_settings = $entity->getDateSettings();
+    $default_pattern = $date_settings['default_pattern'];
+    $separator = $date_settings['separator'] ?: '';
+
+    // Strings containing the ISO-8601 representations of the start and end
+    // date can be used to determine if the day, month or year is the same.
+    $start_iso_8601 = $start_date_time->format('Y-m-d');
+    $end_iso_8601 = $end_date_time->format('Y-m-d');
+
+    if ($start_iso_8601 === $end_iso_8601) {
+      // The range is a single day.
+      return $this->dateFormatter->format($start_timestamp, 'custom',
+        $default_pattern, $timezone, $langcode);
+    }
+    elseif (substr($start_iso_8601, 0, 7) === substr($end_iso_8601, 0, 7)) {
+      // The range spans several days within the same month.
+      $start_pattern = isset($date_settings['same_month_start_pattern']) ? $date_settings['same_month_start_pattern'] : '';
+      $end_pattern = isset($date_settings['same_month_end_pattern']) ? $date_settings['same_month_end_pattern'] : '';
+      if ($start_pattern && $end_pattern) {
+        $start_text = $this->dateFormatter->format($start_timestamp, 'custom', $start_pattern, $timezone, $langcode);
+        $end_text = $this->dateFormatter->format($end_timestamp, 'custom', $end_pattern, $timezone, $langcode);
+        return $start_text . $separator . $end_text;
+      }
+    }
+    elseif (substr($start_iso_8601, 0, 4) === substr($end_iso_8601, 0, 4)) {
+      // The range spans several months within the same year.
+      $start_pattern = isset($date_settings['same_year_start_pattern']) ? $date_settings['same_year_start_pattern'] : '';
+      $end_pattern = isset($date_settings['same_year_end_pattern']) ? $date_settings['same_year_end_pattern'] : '';
+      if ($start_pattern && $end_pattern) {
+        $start_text = $this->dateFormatter->format($start_timestamp, 'custom', $start_pattern, $timezone, $langcode);
+        $end_text = $this->dateFormatter->format($end_timestamp, 'custom', $end_pattern, $timezone, $langcode);
+        return $start_text . $separator . $end_text;
+      }
+    }
+
+    // Fallback: show the start and end dates in full using the default
+    // pattern. This is the case if the range spans different years,
+    // or if the other patterns are not specified.
+    $start_text = $this->dateFormatter->format($start_timestamp, 'custom', $default_pattern, $timezone, $langcode);
+    $end_text = $this->dateFormatter->format($end_timestamp, 'custom', $default_pattern, $timezone, $langcode);
+    return $start_text . $separator . $end_text;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function formatDateTimeRange($start_timestamp, $end_timestamp, $type = 'medium', $timezone = NULL, $langcode = NULL) {
+    $start_date_time = DrupalDateTime::createFromTimestamp($start_timestamp, $timezone);
+    $end_date_time = DrupalDateTime::createFromTimestamp($end_timestamp, $timezone);
+
+    /** @var \Drupal\datetime_extras\Entity\DateRangeFormatInterface $entity */
+    $entity = $this->dateRangeFormatStorage->load($type);
+    $datetime_settings = $entity->getDateTimeSettings();
+    $default_pattern = $datetime_settings['default_pattern'];
+    $separator = $datetime_settings['separator'] ?: '';
+
+    // Strings containing the ISO-8601 representations of the start and end
+    // datetime can be used to determine if the date and/or time are the same.
+    $start_iso_8601 = $start_date_time->format('Y-m-d\TH:i:s');
+    $end_iso_8601 = $end_date_time->format('Y-m-d\TH:i:s');
+
+    if ($start_iso_8601 === $end_iso_8601) {
+      // The range is a single date and time.
+      return $this->dateFormatter->format($start_timestamp, 'custom',
+          $default_pattern, $timezone, $langcode);
+    }
+    elseif (substr($start_iso_8601, 0, 10) == substr($end_iso_8601, 0, 10)) {
+      // The range is contained within a single day.
+      $start_pattern = isset($datetime_settings['same_day_start_pattern']) ? $datetime_settings['same_day_start_pattern'] : '';
+      $end_pattern = isset($datetime_settings['same_day_end_pattern']) ? $datetime_settings['same_day_end_pattern'] : '';
+      if ($start_pattern && $end_pattern) {
+        $start_text = $this->dateFormatter->format($start_timestamp, 'custom', $start_pattern, $timezone, $langcode);
+        $end_text = $this->dateFormatter->format($end_timestamp, 'custom', $end_pattern, $timezone, $langcode);
+        return $start_text . $separator . $end_text;
+      }
+    }
+
+    // Fallback: show the start and end datetimes in full using the default
+    // pattern. This is the case if the range spans different days,
+    // or if the other patterns are not specified.
+    $start_text = $this->dateFormatter->format($start_timestamp, 'custom', $default_pattern, $timezone, $langcode);
+    $end_text = $this->dateFormatter->format($end_timestamp, 'custom', $default_pattern, $timezone, $langcode);
+    return $start_text . $separator . $end_text;
+  }
+
+}
diff --git a/src/DateRangeFormatterInterface.php b/src/DateRangeFormatterInterface.php
new file mode 100644
index 0000000..e1711fa
--- /dev/null
+++ b/src/DateRangeFormatterInterface.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace Drupal\datetime_extras;
+
+/**
+ * Provides an interface defining a date range formatter.
+ *
+ * @package Drupal\datetime_extras
+ */
+interface DateRangeFormatterInterface {
+
+  /**
+   * Formats a date range, using a datetime range format type.
+   *
+   * @param int $start_timestamp
+   *   A UNIX timestamp representing the start time.
+   * @param int $end_timestamp
+   *   A UNIX timestamp representing the end time.
+   * @param string $type
+   *   (optional) The format to use, one of:
+   *   - One of the built-in formats: 'short', 'medium', 'long'.
+   *   - The machine name of an administrator-defined datetime range format.
+   *   Defaults to 'medium'.
+   * @param string|null $timezone
+   *   (optional) Time zone identifier, as described at
+   *   http://php.net/manual/timezones.php Defaults to the time zone used to
+   *   display the page.
+   * @param string|null $langcode
+   *   (optional) Language code to translate to. NULL (default) means to use
+   *   the user interface language for the page.
+   *
+   * @return string
+   *   A translated date & time range string in the requested format.
+   *   Since the format may contain user input, this value should be escaped
+   *   when output.
+   */
+  public function formatDateRange($start_timestamp, $end_timestamp, $type = 'medium', $timezone = NULL, $langcode = NULL);
+
+  /**
+   * Formats a date and time range, using a datetime range format type.
+   *
+   * @param int $start_timestamp
+   *   A UNIX timestamp representing the start time.
+   * @param int $end_timestamp
+   *   A UNIX timestamp representing the end time.
+   * @param string $type
+   *   (optional) The format to use, one of:
+   *   - One of the built-in formats: 'short', 'medium', 'long'.
+   *   - The machine name of an administrator-defined datetime range format.
+   *   Defaults to 'medium'.
+   * @param string|null $timezone
+   *   (optional) Time zone identifier, as described at
+   *   http://php.net/manual/timezones.php Defaults to the time zone used to
+   *   display the page.
+   * @param string|null $langcode
+   *   (optional) Language code to translate to. NULL (default) means to use
+   *   the user interface language for the page.
+   *
+   * @return string
+   *   A translated date & time range string in the requested format.
+   *   Since the format may contain user input, this value should be escaped
+   *   when output.
+   */
+  public function formatDateTimeRange($start_timestamp, $end_timestamp, $type = 'medium', $timezone = NULL, $langcode = NULL);
+
+}
diff --git a/src/Entity/DateRangeFormat.php b/src/Entity/DateRangeFormat.php
new file mode 100644
index 0000000..58caab6
--- /dev/null
+++ b/src/Entity/DateRangeFormat.php
@@ -0,0 +1,78 @@
+<?php
+
+namespace Drupal\datetime_extras\Entity;
+
+use Drupal\Core\Config\Entity\ConfigEntityBase;
+
+/**
+ * Defines the date range format entity.
+ *
+ * @ConfigEntityType(
+ *   id = "date_range_format",
+ *   label = @Translation("Date range format"),
+ *   handlers = {
+ *     "list_builder" = "Drupal\datetime_extras\DateRangeFormatListBuilder",
+ *     "form" = {
+ *       "add" = "Drupal\datetime_extras\Form\DateRangeFormatForm",
+ *       "edit" = "Drupal\datetime_extras\Form\DateRangeFormatForm",
+ *       "delete" = "Drupal\datetime_extras\Form\DateRangeFormatDeleteForm"
+ *     },
+ *     "route_provider" = {
+ *       "html" = "Drupal\Core\Entity\Routing\AdminHtmlRouteProvider",
+ *     },
+ *   },
+ *   config_prefix = "date_range_format",
+ *   admin_permission = "administer site configuration",
+ *   list_cache_tags = { "rendered" },
+ *   entity_keys = {
+ *     "id" = "id",
+ *     "label" = "label",
+ *     "uuid" = "uuid"
+ *   },
+ *   links = {
+ *     "canonical" = "/admin/config/regional/date_range_format/{date_range_format}",
+ *     "add-form" = "/admin/config/regional/date_range_format/add",
+ *     "edit-form" = "/admin/config/regional/date_range_format/{date_range_format}/edit",
+ *     "delete-form" = "/admin/config/regional/date_range_format/{date_range_format}/delete",
+ *     "collection" = "/admin/config/regional/date_range_format"
+ *   }
+ * )
+ */
+class DateRangeFormat extends ConfigEntityBase implements DateRangeFormatInterface {
+
+  /**
+   * The Date range format ID.
+   *
+   * @var string
+   */
+  protected $id;
+
+  /**
+   * The Date range format label.
+   *
+   * @var string
+   */
+  protected $label;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDateSettings() {
+    return $this->get('date_settings');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDateTimeSettings() {
+    return $this->get('datetime_settings');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheTagsToInvalidate() {
+    return ['rendered'];
+  }
+
+}
diff --git a/src/Entity/DateRangeFormatInterface.php b/src/Entity/DateRangeFormatInterface.php
new file mode 100644
index 0000000..cf357d0
--- /dev/null
+++ b/src/Entity/DateRangeFormatInterface.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Drupal\datetime_extras\Entity;
+
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+
+/**
+ * Provides an interface for defining a date range format.
+ */
+interface DateRangeFormatInterface extends ConfigEntityInterface {
+
+  /**
+   * The settings for use when displaying date only ranges.
+   *
+   * @return array
+   *   An array with the following keys:
+   *     - "default_pattern" - the default format pattern, used when the
+   *          start and end values are the same, or the range spans multiple
+   *          years
+   *     - "separator" - the separator string to place in between the
+   *          start and end values
+   *     - "same_day_start_pattern" - the format pattern to use for the
+   *          start date, when the range is contained within a single day
+   *     - "same_day_end_pattern" - the format pattern to use for the
+   *          end date, when the range is contained within a single day
+   */
+  public function getDateSettings();
+
+  /**
+   * The settings for use when displaying date & time ranges.
+   *
+   * @return array
+   *   An array with the following keys:
+   *     - "default_pattern" - the default format pattern, used when the
+   *          start and end values are the same, or the range spans
+   *          multiple days
+   *     - "separator" - the separator string to place in between the
+   *          start and end values
+   *     - "same_day_start_pattern" - the format pattern to use for the
+   *          start date & time, when the range is contained within a single day
+   *     - "same_day_end_pattern" - the format pattern to use for the
+   *          end date & time, when the range is contained within a single day
+   */
+  public function getDateTimeSettings();
+
+}
diff --git a/src/Form/DateRangeFormatDeleteForm.php b/src/Form/DateRangeFormatDeleteForm.php
new file mode 100644
index 0000000..0be9e5a
--- /dev/null
+++ b/src/Form/DateRangeFormatDeleteForm.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace Drupal\datetime_extras\Form;
+
+use Drupal\Core\Entity\EntityDeleteForm;
+
+/**
+ * Provides a form for deleting a date range format.
+ *
+ * @package Drupal\datetime_extras\Form
+ */
+class DateRangeFormatDeleteForm extends EntityDeleteForm {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getQuestion() {
+    return $this->t('Are you sure you want to delete the date range format %name?', ['%name' => $this->entity->label()]);
+  }
+
+}
diff --git a/src/Form/DateRangeFormatForm.php b/src/Form/DateRangeFormatForm.php
new file mode 100644
index 0000000..19c0355
--- /dev/null
+++ b/src/Form/DateRangeFormatForm.php
@@ -0,0 +1,223 @@
+<?php
+
+namespace Drupal\datetime_extras\Form;
+
+use Drupal\Core\Entity\EntityForm;
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Provides a form for editing date range formats.
+ *
+ * @package Drupal\datetime_extras\Form
+ */
+class DateRangeFormatForm extends EntityForm {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function form(array $form, FormStateInterface $form_state) {
+    $form = parent::form($form, $form_state);
+
+    /** @var \Drupal\datetime_extras\Entity\DateRangeFormatInterface $format */
+    $format = $this->entity;
+
+    $form['label'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Label'),
+      '#maxlength' => 255,
+      '#default_value' => $format->label(),
+      '#description' => $this->t("Name of the date time range format."),
+      '#required' => TRUE,
+    ];
+
+    $form['id'] = [
+      '#type' => 'machine_name',
+      '#default_value' => $format->id(),
+      '#machine_name' => [
+        'exists' => '\Drupal\datetime_extras\Entity\DateRangeFormat::load',
+      ],
+      '#disabled' => !$format->isNew(),
+    ];
+
+    $date_settings = $format->getDateSettings();
+
+    $form['date'] = [
+      '#type' => 'vertical_tabs',
+      '#title' => $this->t('Date only formats'),
+      '#tree' => FALSE,
+    ];
+
+    $form['date']['basic'] = [
+      '#type' => 'details',
+      '#title' => $this->t('Basic'),
+      '#open' => TRUE,
+      '#weight' => 1,
+      '#group' => 'date',
+      '#description' => $this->t('Basic date format used for single dates, or ranges that cannot be shown in a compact form.'),
+    ];
+
+    $form['date']['basic']['default_pattern'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Pattern'),
+      '#default_value' => $date_settings['default_pattern'] ?: '',
+      '#maxlength' => 100,
+      '#description' => $this->t('A user-defined date format. See the <a href="http://php.net/manual/function.date.php">PHP manual</a> for available options.'),
+      '#required' => TRUE,
+      '#parents' => ['date_settings', 'default_pattern'],
+    ];
+
+    $form['date']['basic']['separator'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Separator'),
+      '#default_value' => $date_settings['separator'] ?: '',
+      '#maxlength' => 100,
+      '#size' => 10,
+      '#description' => $this->t('Text between start and end dates.'),
+      '#required' => FALSE,
+      '#parents' => ['date_settings', 'separator'],
+    ];
+
+    $form['date']['same_month'] = [
+      '#type' => 'details',
+      '#title' => $this->t('Same month'),
+      '#open' => TRUE,
+      '#weight' => 2,
+      '#group' => 'date',
+      '#description' => $this->t('Optional formatting of date ranges that span multiple days within the same month.'),
+    ];
+
+    $form['date']['same_month']['same_month_start_pattern'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Start date pattern'),
+      '#default_value' => $date_settings['same_month_start_pattern'] ?: '',
+      '#maxlength' => 100,
+      '#description' => $this->t('A user-defined date format. See the <a href="http://php.net/manual/function.date.php">PHP manual</a> for available options.'),
+      '#parents' => ['date_settings', 'same_month_start_pattern'],
+    ];
+
+    $form['date']['same_month']['same_month_end_pattern'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('End date pattern'),
+      '#default_value' => $date_settings['same_month_end_pattern'] ?: '',
+      '#maxlength' => 100,
+      '#description' => $this->t('A user-defined date format. See the <a href="http://php.net/manual/function.date.php">PHP manual</a> for available options.'),
+      '#parents' => ['date_settings', 'same_month_end_pattern'],
+    ];
+
+    $form['date']['same_year'] = [
+      '#type' => 'details',
+      '#title' => $this->t('Same year'),
+      '#open' => TRUE,
+      '#weight' => 2,
+      '#group' => 'date',
+      '#description' => $this->t('Optional formatting of date ranges that span multiple months within the same year.'),
+    ];
+
+    $form['date']['same_year']['same_year_start_pattern'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Start date pattern'),
+      '#default_value' => $date_settings['same_year_start_pattern'] ?: '',
+      '#maxlength' => 100,
+      '#description' => $this->t('A user-defined date format. See the <a href="http://php.net/manual/function.date.php">PHP manual</a> for available options.'),
+      '#parents' => ['date_settings', 'same_year_start_pattern'],
+    ];
+
+    $form['date']['same_year']['same_year_end_pattern'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('End date pattern'),
+      '#default_value' => $date_settings['same_year_end_pattern'] ?: '',
+      '#maxlength' => 100,
+      '#description' => $this->t('A user-defined date format. See the <a href="http://php.net/manual/function.date.php">PHP manual</a> for available options.'),
+      '#parents' => ['date_settings', 'same_year_end_pattern'],
+    ];
+
+    $datetime_settings = $format->getDateTimeSettings();
+
+    $form['datetime'] = [
+      '#type' => 'vertical_tabs',
+      '#title' => $this->t('Date & time formats'),
+    ];
+
+    $form['datetime']['basic'] = [
+      '#type' => 'details',
+      '#title' => $this->t('Basic'),
+      '#open' => TRUE,
+      '#weight' => 1,
+      '#group' => 'datetime',
+      '#description' => $this->t('Basic date and time format used for single date/times, or ranges that cannot be shown in a compact form.'),
+    ];
+
+    $form['datetime']['basic']['default_pattern'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Pattern'),
+      '#default_value' => $datetime_settings['default_pattern'] ?: '',
+      '#maxlength' => 100,
+      '#description' => $this->t('A user-defined date format. See the <a href="http://php.net/manual/function.date.php">PHP manual</a> for available options.'),
+      '#required' => TRUE,
+      '#parents' => ['datetime_settings', 'default_pattern'],
+    ];
+
+    $form['datetime']['basic']['separator'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Separator'),
+      '#default_value' => $datetime_settings['separator'] ?: '',
+      '#maxlength' => 100,
+      '#size' => 10,
+      '#description' => $this->t('Text between start and end date/times.'),
+      '#required' => FALSE,
+      '#parents' => ['datetime_settings', 'separator'],
+    ];
+
+    $form['datetime']['same_day'] = [
+      '#type' => 'details',
+      '#title' => $this->t('Same day'),
+      '#open' => TRUE,
+      '#weight' => 2,
+      '#group' => 'datetime',
+      '#description' => $this->t('Optional formatting of time ranges within a single day.'),
+    ];
+
+    $form['datetime']['same_day']['same_day_start_pattern'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Start date/time pattern'),
+      '#default_value' => $datetime_settings['same_day_start_pattern'] ?: '',
+      '#maxlength' => 100,
+      '#description' => $this->t('A user-defined date format. See the <a href="http://php.net/manual/function.date.php">PHP manual</a> for available options.'),
+      '#parents' => ['datetime_settings', 'same_day_start_pattern'],
+    ];
+
+    $form['datetime']['same_day']['same_day_end_pattern'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('End date/time pattern'),
+      '#default_value' => $datetime_settings['same_day_end_pattern'] ?: '',
+      '#maxlength' => 100,
+      '#description' => $this->t('A user-defined date format. See the <a href="http://php.net/manual/function.date.php">PHP manual</a> for available options.'),
+      '#parents' => ['datetime_settings', 'same_day_end_pattern'],
+    ];
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function save(array $form, FormStateInterface $form_state) {
+    $date_range_format = $this->entity;
+    $status = $date_range_format->save();
+
+    switch ($status) {
+      case SAVED_NEW:
+        drupal_set_message($this->t('Created the %label date range format.', [
+          '%label' => $date_range_format->label(),
+        ]));
+        break;
+
+      default:
+        drupal_set_message($this->t('Updated the %label date range format.', [
+          '%label' => $date_range_format->label(),
+        ]));
+    }
+    $form_state->setRedirectUrl($date_range_format->toUrl('collection'));
+  }
+
+}
diff --git a/src/Plugin/Field/FieldFormatter/DateRangeCompactFormatter.php b/src/Plugin/Field/FieldFormatter/DateRangeCompactFormatter.php
new file mode 100644
index 0000000..d6d9f9e
--- /dev/null
+++ b/src/Plugin/Field/FieldFormatter/DateRangeCompactFormatter.php
@@ -0,0 +1,168 @@
+<?php
+
+namespace Drupal\datetime_extras\Plugin\Field\FieldFormatter;
+
+use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\Core\Field\FieldDefinitionInterface;
+use Drupal\Core\Field\FieldItemListInterface;
+use Drupal\Core\Field\FormatterBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\datetime_extras\DateRangeFormatterInterface;
+use Drupal\datetime\Plugin\Field\FieldType\DateTimeItem;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Plugin implementation of the 'Compact' formatter for 'daterange' fields.
+ *
+ * This formatter renders the data range using <time> elements, with
+ * configurable date formats (from the list of configured formats) and a
+ * separator.
+ *
+ * @FieldFormatter(
+ *   id = "daterange_compact",
+ *   label = @Translation("Compact"),
+ *   field_types = {
+ *     "daterange"
+ *   }
+ * )
+ */
+class DateRangeCompactFormatter extends FormatterBase implements ContainerFactoryPluginInterface {
+
+  /**
+   * The date range formatter service.
+   *
+   * @var \Drupal\datetime_extras\DateRangeFormatterInterface
+   */
+  protected $dateRangeFormatter;
+
+  /**
+   * The date range format entity storage.
+   *
+   * @var \Drupal\Core\Entity\EntityStorageInterface
+   */
+  protected $dateRangeFormatStorage;
+
+  /**
+   * Constructs a new DateRangeCompactFormatter.
+   *
+   * @param string $plugin_id
+   *   The plugin_id for the formatter.
+   * @param mixed $plugin_definition
+   *   The plugin implementation definition.
+   * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
+   *   The definition of the field to which the formatter is associated.
+   * @param array $settings
+   *   The formatter settings.
+   * @param string $label
+   *   The formatter label display setting.
+   * @param string $view_mode
+   *   The view mode.
+   * @param array $third_party_settings
+   *   Third party settings.
+   * @param \Drupal\datetime_extras\DateRangeFormatterInterface $date_range_formatter
+   *   The date formatter service.
+   * @param \Drupal\Core\Entity\EntityStorageInterface $date_range_format_storage
+   *   The date format entity storage.
+   */
+  public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, $label, $view_mode, array $third_party_settings, DateRangeFormatterInterface $date_range_formatter, EntityStorageInterface $date_range_format_storage) {
+    parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $label, $view_mode, $third_party_settings);
+
+    $this->dateRangeFormatter = $date_range_formatter;
+    $this->dateRangeFormatStorage = $date_range_format_storage;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    return new static(
+      $plugin_id,
+      $plugin_definition,
+      $configuration['field_definition'],
+      $configuration['settings'],
+      $configuration['label'],
+      $configuration['view_mode'],
+      $configuration['third_party_settings'],
+      $container->get('datetime_extras.date_range.formatter'),
+      $container->get('entity_type.manager')->getStorage('date_range_format')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function defaultSettings() {
+    return [
+      'format_type' => 'medium',
+    ] + parent::defaultSettings();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function settingsForm(array $form, FormStateInterface $form_state) {
+    $form = parent::settingsForm($form, $form_state);
+
+    $format_types = $this->dateRangeFormatStorage->loadMultiple();
+    $options = [];
+    foreach ($format_types as $type => $type_info) {
+      $options[$type] = $type_info->label();
+    }
+
+    $form['format_type'] = [
+      '#type' => 'select',
+      '#title' => $this->t('Date and time range format'),
+      '#description' => $this->t("Choose a format for displaying the date and time range."),
+      '#options' => $options,
+      '#default_value' => $this->getSetting('format_type'),
+    ];
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function settingsSummary() {
+    $summary = parent::settingsSummary();
+    $summary[] = $this->t('Format: @format', ['@format' => $this->getSetting('format_type')]);
+    return $summary;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function viewElements(FieldItemListInterface $items, $langcode) {
+    $elements = [];
+
+    foreach ($items as $delta => $item) {
+      if (!empty($item->start_date) && !empty($item->end_date)) {
+        $start_timestamp = $item->start_date->getTimestamp();
+        $end_timestamp = $item->end_date->getTimestamp();
+        $format = $this->getSetting('format_type');
+
+        if ($this->getFieldSetting('datetime_type') == DateTimeItem::DATETIME_TYPE_DATE) {
+          $timezone = DATETIME_STORAGE_TIMEZONE;
+          $text = $this->dateRangeFormatter->formatDateRange($start_timestamp, $end_timestamp, $format, $timezone);
+        }
+        else {
+          $timezone = drupal_get_user_timezone();
+          $text = $this->dateRangeFormatter->formatDateTimeRange($start_timestamp, $end_timestamp, $format, $timezone);
+        }
+
+        $elements[$delta] = [
+          '#plain_text' => $text,
+          '#cache' => [
+            'contexts' => [
+              'timezone',
+            ],
+          ],
+        ];
+      }
+    }
+
+    return $elements;
+  }
+
+}
diff --git a/src/Tests/DateRangeCompactFormatterTest.php b/src/Tests/DateRangeCompactFormatterTest.php
new file mode 100644
index 0000000..c707449
--- /dev/null
+++ b/src/Tests/DateRangeCompactFormatterTest.php
@@ -0,0 +1,369 @@
+<?php
+
+namespace Drupal\datetime_extras\Tests;
+
+use Drupal\Component\Utility\Unicode;
+use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
+use Drupal\Core\Entity\Entity\EntityViewDisplay;
+use Drupal\Core\Entity\FieldableEntityInterface;
+use Drupal\datetime_extras\Entity\DateRangeFormat;
+use Drupal\datetime\Plugin\Field\FieldType\DateTimeItem;
+use Drupal\entity_test\Entity\EntityTest;
+use Drupal\field\Entity\FieldConfig;
+use Drupal\field\Entity\FieldStorageConfig;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * Tests compact date range field formatter functionality.
+ *
+ * @group field
+ */
+class DateRangeCompactFormatterTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'system',
+    'field',
+    'datetime',
+    'datetime_range',
+    'datetime_extras',
+    'entity_test',
+    'user',
+  ];
+
+  /**
+   * The name of the entity type used in testing.
+   *
+   * @var string
+   */
+  protected $entityType;
+
+  /**
+   * The name of the bundle used for testing.
+   *
+   * @var string
+   */
+  protected $bundle;
+
+  /**
+   * The made up name of the date-only range field.
+   *
+   * @var string
+   */
+  protected $dateFieldName;
+
+  /**
+   * The made up name of the date & time range field.
+   *
+   * @var string
+   */
+  protected $dateTimeFieldName;
+
+  /**
+   * The default display for this entity.
+   *
+   * @var \Drupal\Core\Entity\Display\EntityViewDisplayInterface
+   */
+  protected $defaultDisplay;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->installConfig(['system']);
+    $this->installConfig(['field']);
+    $this->installConfig(['datetime_extras']);
+    $this->installEntitySchema('entity_test');
+
+    $this->entityType = 'entity_test';
+    $this->bundle = $this->entityType;
+    $this->dateFieldName = Unicode::strtolower($this->randomMachineName());
+    $this->dateTimeFieldName = Unicode::strtolower($this->randomMachineName());
+
+    // Create a typical format for USA.
+    $usa_format = DateRangeFormat::create([
+      'id' => 'usa',
+      'label' => 'USA',
+      'date_settings' => [
+        'default_pattern' => 'F jS, Y',
+        'separator' => ' - ',
+        'same_month_start_pattern' => 'F jS',
+        'same_month_end_pattern' => 'jS, Y',
+        'same_year_start_pattern' => 'F jS',
+        'same_year_end_pattern' => 'F jS, Y',
+      ],
+      'datetime_settings' => [
+        'default_pattern' => 'g:ia \o\n F jS, Y',
+        'separator' => ' - ',
+        'same_day_start_pattern' => 'g:ia',
+        'same_day_end_pattern' => 'g:ia \o\n F jS, Y',
+      ],
+    ]);
+    $usa_format->save();
+
+    // Create a ISO-8601 format without any compact variations.
+    $iso_8601_format = DateRangeFormat::create([
+      'id' => 'iso_8601',
+      'label' => 'ISO-8601',
+      'date_settings' => [
+        'default_pattern' => 'Y-m-d',
+        'separator' => ' - ',
+      ],
+      'datetime_settings' => [
+        'default_pattern' => 'Y-m-d\TH:i:s',
+        'separator' => ' - ',
+      ],
+    ]);
+    $iso_8601_format->save();
+
+    $date_field_storage = FieldStorageConfig::create([
+      'field_name' => $this->dateFieldName,
+      'entity_type' => $this->entityType,
+      'type' => 'daterange',
+      'settings' => [
+        'datetime_type' => DateTimeItem::DATETIME_TYPE_DATE,
+      ],
+    ]);
+    $date_field_storage->save();
+
+    $date_field_instance = FieldConfig::create([
+      'field_storage' => $date_field_storage,
+      'bundle' => $this->bundle,
+      'label' => $this->randomMachineName(),
+    ]);
+    $date_field_instance->save();
+
+    $date_time_field_storage = FieldStorageConfig::create([
+      'field_name' => $this->dateTimeFieldName,
+      'entity_type' => $this->entityType,
+      'type' => 'daterange',
+      'settings' => [
+        'datetime_type' => DateTimeItem::DATETIME_TYPE_DATETIME,
+      ],
+    ]);
+    $date_time_field_storage->save();
+
+    $date_time_field_instance = FieldConfig::create([
+      'field_storage' => $date_time_field_storage,
+      'bundle' => $this->bundle,
+      'label' => $this->randomMachineName(),
+    ]);
+    $date_time_field_instance->save();
+
+    $this->defaultDisplay = EntityViewDisplay::create([
+      'targetEntityType' => $this->entityType,
+      'bundle' => $this->bundle,
+      'mode' => 'default',
+      'status' => TRUE,
+    ]);
+    $this->defaultDisplay->setComponent($this->dateFieldName, [
+      'type' => 'daterange_compact',
+      'settings' => [
+        'format_type' => 'medium',
+      ],
+    ]);
+    $this->defaultDisplay->setComponent($this->dateTimeFieldName, [
+      'type' => 'daterange_compact',
+      'settings' => [
+        'format_type' => 'medium',
+      ],
+    ]);
+    $this->defaultDisplay->save();
+  }
+
+  /**
+   * Renders fields of a given entity with a given display.
+   *
+   * @param \Drupal\Core\Entity\FieldableEntityInterface $entity
+   *   The entity object with attached fields to render.
+   * @param \Drupal\Core\Entity\Display\EntityViewDisplayInterface $display
+   *   The display to render the fields in.
+   *
+   * @return string
+   *   The rendered entity fields.
+   */
+  protected function renderEntityFields(FieldableEntityInterface $entity, EntityViewDisplayInterface $display) {
+    $content = $display->build($entity);
+    $content = $this->render($content);
+    return $content;
+  }
+
+  /**
+   * Tests the display of date-only range fields.
+   */
+  public function testDateRanges() {
+    $all_data = [];
+
+    // Same day.
+    $all_data[] = [
+      'start' => '2017-01-01',
+      'end' => '2017-01-01',
+      'expected' => [
+        'default' => '1 January 2017',
+        'usa' => 'January 1st, 2017',
+        'iso_8601' => '2017-01-01',
+      ],
+    ];
+
+    // Different days, same month.
+    $all_data[] = [
+      'start' => '2017-01-02',
+      'end' => '2017-01-03',
+      'expected' => [
+        'default' => '2–3 January 2017',
+        'usa' => 'January 2nd - 3rd, 2017',
+        'iso_8601' => '2017-01-02 - 2017-01-03',
+      ],
+    ];
+
+    // Different months, same year.
+    $all_data[] = [
+      'start' => '2017-01-04',
+      'end' => '2017-02-05',
+      'expected' => [
+        'default' => '4 January–5 February 2017',
+        'usa' => 'January 4th - February 5th, 2017',
+        'iso_8601' => '2017-01-04 - 2017-02-05',
+      ],
+    ];
+
+    // Different years.
+    $all_data[] = [
+      'start' => '2017-01-06',
+      'end' => '2018-02-07',
+      'expected' => [
+        'default' => '6 January 2017–7 February 2018',
+        'usa' => 'January 6th, 2017 - February 7th, 2018',
+        'iso_8601' => '2017-01-06 - 2018-02-07',
+      ],
+    ];
+
+    foreach ($all_data as $data) {
+      foreach ($data['expected'] as $format => $expected) {
+        $entity = EntityTest::create([]);
+        $entity->{$this->dateFieldName}->value = $data['start'];
+        $entity->{$this->dateFieldName}->end_value = $data['end'];
+
+        if ($format === 'default') {
+          $this->renderEntityFields($entity, $this->defaultDisplay);
+        }
+        else {
+          // For testing a custom format, don't use the entity's default
+          // display, but instead render the field using a temporary one.
+          $display = $this->buildCustomDisplay($this->dateFieldName, $format);
+          $this->renderEntityFields($entity, $display);
+        }
+
+        $this->assertRaw($expected);
+      }
+    }
+  }
+
+  /**
+   * Tests the display of date and time range fields.
+   */
+  public function testDateTimeRanges() {
+    $all_data = [];
+
+    // Note: the default timezone for unit tests is Australia/Sydney
+    // see https://www.drupal.org/node/2498619 for why
+    // Australia/Sydney is UTC +10:00 (normal) or UTC +11:00 (DST)
+    // DST starts first Sunday in October
+    // DST ends first Sunday in April.
+    // Same day.
+    $all_data[] = [
+      'start' => '2017-01-01T09:00:00',
+      'end' => '2017-01-01T12:00:00',
+      'expected' => [
+        'default' => '1 January 2017 20:00–23:00',
+        'usa' => '8:00pm - 11:00pm on January 1st, 2017',
+        'iso_8601' => '2017-01-01T20:00:00 - 2017-01-01T23:00:00',
+      ],
+    ];
+
+    // Different day in UTC, same day in Australia.
+    $all_data[] = [
+      'start' => '2017-01-01T23:00:00',
+      'end' => '2017-01-02T01:00:00',
+      'expected' => [
+        'default' => '2 January 2017 10:00–12:00',
+        'usa' => '10:00am - 12:00pm on January 2nd, 2017',
+        'iso_8601' => '2017-01-02T10:00:00 - 2017-01-02T12:00:00',
+      ],
+    ];
+
+    // Same day in UTC, different day in Australia.
+    $all_data[] = [
+      'start' => '2017-01-01T12:00:00',
+      'end' => '2017-01-01T15:00:00',
+      'expected' => [
+        'default' => '1 January 2017 23:00–2 January 2017 02:00',
+        'usa' => '11:00pm on January 1st, 2017 - 2:00am on January 2nd, 2017',
+        'iso_8601' => '2017-01-01T23:00:00 - 2017-01-02T02:00:00',
+      ],
+    ];
+
+    // Different days in UTC and Australia, also spans DST change.
+    $all_data[] = [
+      'start' => '2017-04-01T01:00:00',
+      'end' => '2017-04-08T01:00:00',
+      'expected' => [
+        'default' => '1 April 2017 12:00–8 April 2017 11:00',
+        'usa' => '12:00pm on April 1st, 2017 - 11:00am on April 8th, 2017',
+        'iso_8601' => '2017-04-01T12:00:00 - 2017-04-08T11:00:00',
+      ],
+    ];
+
+    foreach ($all_data as $data) {
+      foreach ($data['expected'] as $format => $expected) {
+        $entity = EntityTest::create([]);
+        $entity->{$this->dateTimeFieldName}->value = $data['start'];
+        $entity->{$this->dateTimeFieldName}->end_value = $data['end'];
+
+        if ($format === 'default') {
+          $this->renderEntityFields($entity, $this->defaultDisplay);
+        }
+        else {
+          // For testing a custom format, don't use the entity's default
+          // display, but instead render the field using a temporary one.
+          $display = $this->buildCustomDisplay($this->dateTimeFieldName, $format);
+          $this->renderEntityFields($entity, $display);
+        }
+
+        $this->assertRaw($expected);
+      }
+    }
+  }
+
+  /**
+   * Helper function that creates a display object for the given format.
+   *
+   * @param string $field_name
+   *   The name of the field.
+   * @param string $format
+   *   The name of the format.
+   *
+   * @return \Drupal\Core\Entity\Display\EntityViewDisplayInterface
+   *   A temporary display object.
+   */
+  private function buildCustomDisplay($field_name, $format) {
+    $display = EntityViewDisplay::create([
+      'targetEntityType' => $this->entityType,
+      'bundle' => $this->bundle,
+      'mode' => 'default',
+      'status' => TRUE,
+    ]);
+    $display->setComponent($field_name, [
+      'type' => 'daterange_compact',
+      'settings' => [
+        'format_type' => $format,
+      ],
+    ]);
+    return $display;
+  }
+
+}
