diff --git a/inc/tz/expand.php b/inc/tz/expand.php
new file mode 100644
index 00000000..c3f2652c
--- /dev/null
+++ b/inc/tz/expand.php
@@ -0,0 +1,157 @@
+
+* @copyright Morphoss Ltd
+* @license http://gnu.org/copyleft/gpl.html GNU GPL v3 or later
+*/
+
+require_once('vCalendar.php');
+require_once('RRule-v2.php');
+
+if ( empty($format) ) $format = 'text/calendar';
+if ( $format != 'text/calendar' ) {
+ $request->PreconditionFailed(403, 'supported-format', 'This server currently only supports text/calendar format.');
+}
+
+if ( empty($start) ) $start = sprintf( '%04d-01-01', date('Y'));
+if ( empty($end) ) $end = sprintf( '%04d-12-31', date('Y') + 10);
+
+$sql = 'SELECT our_tzno, tzid, active, olson_name, vtimezone, etag, ';
+$sql .= 'to_char(last_modified AT TIME ZONE \'UTC\',\'Dy, DD Mon IYYY HH24:MI:SS "GMT"\') AS last_modified ';
+$sql .= 'FROM timezones WHERE tzid=:tzid';
+$params = array( ':tzid' => $tzid );
+$qry = new AwlQuery($sql,$params);
+if ( !$qry->Exec() ) exit(1);
+if ( $qry->rows() < 1 ) {
+ $sql = 'SELECT our_tzno, tzid, active, olson_name, vtimezone, etag, ';
+ $sql .= 'to_char(last_modified AT TIME ZONE \'UTC\',\'Dy, DD Mon IYYY HH24:MI:SS "GMT"\') AS last_modified ';
+ $sql .= 'FROM timezones JOIN tz_aliases USING(our_tzno) WHERE tzalias=:tzid';
+ if ( !$qry->Exec() ) exit(1);
+ if ( $qry->rows() < 1 ) $request->DoResponse(404);
+}
+
+$tz = $qry->Fetch();
+
+// define( 'DEBUG_EXPAND', true);
+define( 'DEBUG_EXPAND', false );
+
+
+/**
+* Expand the instances for a STANDARD or DAYLIGHT component of a VTIMEZONE
+*
+* @param object $vResource is a VCALENDAR with a VTIMEZONE containing components needing expansion
+* @param object $range_start A RepeatRuleDateTime which is the beginning of the range for events.
+* @param object $range_end A RepeatRuleDateTime which is the end of the range for events.
+*
+* @return array of onset datetimes with UTC from/to offsets
+*/
+function expand_timezone_onsets( vCalendar $vResource, RepeatRuleDateTime $range_start, RepeatRuleDateTime $range_end ) {
+ global $c;
+ $vtimezones = $vResource->GetComponents();
+ $vtz = $vtimezones[0];
+ $components = $vtz->GetComponents();
+
+ $instances = array();
+ $dtstart = null;
+ $is_date = false;
+ $has_repeats = false;
+ $zone_tz = $vtz->GetPValue('TZID');
+ $range_start->setTimeZone($zone_tz);
+ $range_end->setTimeZone($zone_tz);
+
+ foreach( $components AS $k => $comp ) {
+ if ( DEBUG_EXPAND ) {
+ printf( "Starting TZ expansion for component '%s' in timezone '%s'\n", $comp->GetType(), $zone_tz);
+ foreach( $instances AS $k => $v ) {
+ print ' : '.$k;
+ }
+ print "\n";
+ }
+ $dtstart_prop = $comp->GetProperty('DTSTART');
+ if ( !isset($dtstart_prop) ) continue;
+ $dtstart_prop->SetParameterValue('TZID',$zone_tz);
+ $dtstart = new RepeatRuleDateTime( $dtstart_prop );
+ $is_date = $dtstart->isDate();
+ $instances[$dtstart->FloatOrUTC()] = $comp;
+ $rrule = $comp->GetProperty('RRULE');
+ $has_repeats = isset($rrule);
+ if ( !$has_repeats ) continue;
+
+ $instances += rrule_expand($dtstart, 'RRULE', $comp, $range_end);
+ if ( DEBUG_EXPAND ) {
+ print( "After rrule_expand");
+ foreach( $instances AS $k => $v ) {
+ print ' : '.$k;
+ }
+ print "\n";
+ }
+ $instances += rdate_expand($dtstart, 'RDATE', $comp, $range_end);
+ if ( DEBUG_EXPAND ) {
+ print( "After rdate_expand");
+ foreach( $instances AS $k => $v ) {
+ print ' : '.$k;
+ }
+ print "\n";
+ }
+ }
+
+ ksort($instances);
+
+ $onsets = array();
+ $start_utc = $range_start->FloatOrUTC();
+ $end_utc = $range_end->FloatOrUTC();
+ foreach( $instances AS $utc => $comp ) {
+ if ( $utc > $end_utc ) {
+ if ( DEBUG_EXPAND ) printf( "We're done: $utc is out of the range.\n");
+ break;
+ }
+
+ if ( $utc < $start_utc ) {
+ continue;
+ }
+ $onsets[$utc] = array(
+ 'from' => $comp->GetPValue('TZOFFSETFROM'),
+ 'to' => $comp->GetPValue('TZOFFSETTO'),
+ 'name' => $comp->GetPValue('TZNAME'),
+ 'type' => $comp->GetType()
+ );
+ }
+
+ return $onsets;
+}
+
+header( 'Etag: "'.$tz->etag.'"' );
+header( 'Last-Modified', $tz->last_modified );
+header('Content-Type: application/xml; charset="utf-8"');
+
+$vtz = new vCalendar($tz->vtimezone);
+
+$response = new XMLDocument(array("urn:ietf:params:xml:ns:timezone-service" => ""));
+$timezones = $response->NewXMLElement('urn:ietf:params:xml:ns:timezone-service:timezones');
+$timezones->NewElement('dtstamp', gmdate('Ymd\THis\Z'));
+
+$from = new RepeatRuleDateTime($start);
+$until = new RepeatRuleDateTime($end);
+
+$observances = expand_timezone_onsets($vtz, $from, $until);
+$tzdata = array();
+$tzdata[] = new XMLElement( 'tzid', $tzid );
+$tzdata[] = new XMLElement( 'calscale', 'Gregorian' );
+
+foreach( $observances AS $onset => $details ) {
+ $tzdata[] = new XMLElement( 'observance', array(
+ new XMLElement('name', (empty($details['name']) ? $details['type'] : $details['name'] ) ),
+ new XMLElement('onset', $onset ),
+ new XMLElement('utc-offset-from', substr($details['from'],0,-2).':'.substr($details['from'],-2) ),
+ new XMLElement('utc-offset-to', substr($details['to'],0,-2).':'.substr($details['to'],-2) )
+ ));
+}
+
+$timezones->NewElement('tzdata', $tzdata );
+echo $response->Render($timezones);
+
+exit(0);
\ No newline at end of file
diff --git a/testing/tests/timezone/5040-expand.result b/testing/tests/timezone/5040-expand.result
new file mode 100644
index 00000000..fe6c167f
--- /dev/null
+++ b/testing/tests/timezone/5040-expand.result
@@ -0,0 +1,146 @@
+HTTP/1.1 200 OK
+Date: Dow, 01 Jan 2000 00:00:00 GMT
+Etag: "e87a687e5f1f3b83389ac9472aea42ed"
+Content-Length: 4042
+Content-Type: application/xml; charset="utf-8"
+
+
+
+ all good
+
+ Pacific/Auckland
+ Gregorian
+
+ NZST
+ 20110402T150000Z
+ +13:00
+ +12:00
+
+
+ NZDT
+ 20110924T140000Z
+ +12:00
+ +13:00
+
+
+ NZST
+ 20120331T150000Z
+ +13:00
+ +12:00
+
+
+ NZDT
+ 20120929T140000Z
+ +12:00
+ +13:00
+
+
+ NZST
+ 20130406T150000Z
+ +13:00
+ +12:00
+
+
+ NZDT
+ 20130928T140000Z
+ +12:00
+ +13:00
+
+
+ NZST
+ 20140405T150000Z
+ +13:00
+ +12:00
+
+
+ NZDT
+ 20140927T140000Z
+ +12:00
+ +13:00
+
+
+ NZST
+ 20150404T150000Z
+ +13:00
+ +12:00
+
+
+ NZDT
+ 20150926T140000Z
+ +12:00
+ +13:00
+
+
+ NZST
+ 20160402T150000Z
+ +13:00
+ +12:00
+
+
+ NZDT
+ 20160924T140000Z
+ +12:00
+ +13:00
+
+
+ NZST
+ 20170401T150000Z
+ +13:00
+ +12:00
+
+
+ NZDT
+ 20170923T140000Z
+ +12:00
+ +13:00
+
+
+ NZST
+ 20180331T150000Z
+ +13:00
+ +12:00
+
+
+ NZDT
+ 20180929T140000Z
+ +12:00
+ +13:00
+
+
+ NZST
+ 20190406T150000Z
+ +13:00
+ +12:00
+
+
+ NZDT
+ 20190928T140000Z
+ +12:00
+ +13:00
+
+
+ NZST
+ 20200404T150000Z
+ +13:00
+ +12:00
+
+
+ NZDT
+ 20200926T140000Z
+ +12:00
+ +13:00
+
+
+ NZST
+ 20210403T150000Z
+ +13:00
+ +12:00
+
+
+ NZDT
+ 20210925T140000Z
+ +12:00
+ +13:00
+
+
+
diff --git a/testing/tests/timezone/5040-expand.test b/testing/tests/timezone/5040-expand.test
new file mode 100644
index 00000000..13bfa860
--- /dev/null
+++ b/testing/tests/timezone/5040-expand.test
@@ -0,0 +1,8 @@
+#
+# List timezone server timezones
+#
+TYPE=GET
+URL=http://regression.host/tz.php?action=expand&tzid=Pacific/Auckland
+HEAD
+
+REPLACE=/dtstamp>[0-9TZ]{16}/dtstamp>all good/
diff --git a/testing/tests/timezone/5041-expand.result b/testing/tests/timezone/5041-expand.result
new file mode 100644
index 00000000..74e64075
--- /dev/null
+++ b/testing/tests/timezone/5041-expand.result
@@ -0,0 +1,170 @@
+HTTP/1.1 200 OK
+Date: Dow, 01 Jan 2000 00:00:00 GMT
+Etag: "60b52981cbe6fab393b6f40f24aec132"
+Content-Length: 4720
+Content-Type: application/xml; charset="utf-8"
+
+
+
+ all good
+
+ America/Indiana/Indianapolis
+ Gregorian
+
+ EDT
+ 20090308T070000Z
+ -05:00
+ -04:00
+
+
+ EST
+ 20091101T070000Z
+ -04:00
+ -05:00
+
+
+ EDT
+ 20100314T070000Z
+ -05:00
+ -04:00
+
+
+ EST
+ 20101107T070000Z
+ -04:00
+ -05:00
+
+
+ EDT
+ 20110313T070000Z
+ -05:00
+ -04:00
+
+
+ EST
+ 20111106T070000Z
+ -04:00
+ -05:00
+
+
+ EDT
+ 20120311T070000Z
+ -05:00
+ -04:00
+
+
+ EST
+ 20121104T070000Z
+ -04:00
+ -05:00
+
+
+ EDT
+ 20130310T070000Z
+ -05:00
+ -04:00
+
+
+ EST
+ 20131103T070000Z
+ -04:00
+ -05:00
+
+
+ EDT
+ 20140309T070000Z
+ -05:00
+ -04:00
+
+
+ EST
+ 20141102T070000Z
+ -04:00
+ -05:00
+
+
+ EDT
+ 20150308T070000Z
+ -05:00
+ -04:00
+
+
+ EST
+ 20151101T070000Z
+ -04:00
+ -05:00
+
+
+ EDT
+ 20160313T070000Z
+ -05:00
+ -04:00
+
+
+ EST
+ 20161106T070000Z
+ -04:00
+ -05:00
+
+
+ EDT
+ 20170312T070000Z
+ -05:00
+ -04:00
+
+
+ EST
+ 20171105T070000Z
+ -04:00
+ -05:00
+
+
+ EDT
+ 20180311T070000Z
+ -05:00
+ -04:00
+
+
+ EST
+ 20181104T070000Z
+ -04:00
+ -05:00
+
+
+ EDT
+ 20190310T070000Z
+ -05:00
+ -04:00
+
+
+ EST
+ 20191103T070000Z
+ -04:00
+ -05:00
+
+
+ EDT
+ 20200308T070000Z
+ -05:00
+ -04:00
+
+
+ EST
+ 20201101T070000Z
+ -04:00
+ -05:00
+
+
+ EDT
+ 20210314T070000Z
+ -05:00
+ -04:00
+
+
+ EST
+ 20211107T070000Z
+ -04:00
+ -05:00
+
+
+
diff --git a/testing/tests/timezone/5041-expand.test b/testing/tests/timezone/5041-expand.test
new file mode 100644
index 00000000..8ab553b4
--- /dev/null
+++ b/testing/tests/timezone/5041-expand.test
@@ -0,0 +1,8 @@
+#
+# Fetch an expanded timezone from the server.
+#
+TYPE=GET
+URL=http://regression.host/tz.php?action=expand&tzid=America/Indiana/Indianapolis&start=2009-01-01
+HEAD
+
+REPLACE=/dtstamp>[0-9TZ]{16}/dtstamp>all good/
diff --git a/testing/tests/timezone/5042-expand.result b/testing/tests/timezone/5042-expand.result
new file mode 100644
index 00000000..ca0f8a8d
--- /dev/null
+++ b/testing/tests/timezone/5042-expand.result
@@ -0,0 +1,44 @@
+HTTP/1.1 200 OK
+Date: Dow, 01 Jan 2000 00:00:00 GMT
+Etag: "60b52981cbe6fab393b6f40f24aec132"
+Content-Length: 1108
+Content-Type: application/xml; charset="utf-8"
+
+
+
+ all good
+
+ America/Indiana/Indianapolis
+ Gregorian
+
+ EDT
+ 20110313T070000Z
+ -05:00
+ -04:00
+
+
+ EST
+ 20111106T070000Z
+ -04:00
+ -05:00
+
+
+ EDT
+ 20120311T070000Z
+ -05:00
+ -04:00
+
+
+ EST
+ 20121104T070000Z
+ -04:00
+ -05:00
+
+
+ EDT
+ 20130310T070000Z
+ -05:00
+ -04:00
+
+
+
diff --git a/testing/tests/timezone/5042-expand.test b/testing/tests/timezone/5042-expand.test
new file mode 100644
index 00000000..e576e5b1
--- /dev/null
+++ b/testing/tests/timezone/5042-expand.test
@@ -0,0 +1,8 @@
+#
+# Fetch an expanded timezone from the server.
+#
+TYPE=GET
+URL=http://regression.host/tz.php?action=expand&tzid=America/Indiana/Indianapolis&end=2013-07-01
+HEAD
+
+REPLACE=/dtstamp>[0-9TZ]{16}/dtstamp>all good/
diff --git a/testing/tests/timezone/5043-expand.result b/testing/tests/timezone/5043-expand.result
new file mode 100644
index 00000000..1aeac8a0
--- /dev/null
+++ b/testing/tests/timezone/5043-expand.result
@@ -0,0 +1,74 @@
+HTTP/1.1 200 OK
+Date: Dow, 01 Jan 2000 00:00:00 GMT
+Etag: "60b52981cbe6fab393b6f40f24aec132"
+Content-Length: 1968
+Content-Type: application/xml; charset="utf-8"
+
+
+
+ all good
+
+ America/Indiana/Indianapolis
+ Gregorian
+
+ EDT
+ 20140309T070000Z
+ -05:00
+ -04:00
+
+
+ EST
+ 20141102T070000Z
+ -04:00
+ -05:00
+
+
+ EDT
+ 20150308T070000Z
+ -05:00
+ -04:00
+
+
+ EST
+ 20151101T070000Z
+ -04:00
+ -05:00
+
+
+ EDT
+ 20160313T070000Z
+ -05:00
+ -04:00
+
+
+ EST
+ 20161106T070000Z
+ -04:00
+ -05:00
+
+
+ EDT
+ 20170312T070000Z
+ -05:00
+ -04:00
+
+
+ EST
+ 20171105T070000Z
+ -04:00
+ -05:00
+
+
+ EDT
+ 20180311T070000Z
+ -05:00
+ -04:00
+
+
+ EST
+ 20181104T070000Z
+ -04:00
+ -05:00
+
+
+
diff --git a/testing/tests/timezone/5043-expand.test b/testing/tests/timezone/5043-expand.test
new file mode 100644
index 00000000..f45561a6
--- /dev/null
+++ b/testing/tests/timezone/5043-expand.test
@@ -0,0 +1,8 @@
+#
+# Fetch an expanded timezone from the server.
+#
+TYPE=GET
+URL=http://regression.host/tz.php?action=expand&tzid=America/Indiana/Indianapolis&start=2014-01-01&end=2018-12-31
+HEAD
+
+REPLACE=/dtstamp>[0-9TZ]{16}/dtstamp>all good/