3

Trying to create a .ics file which has a VTIMEZONE component, which based on the supplied timezone sets the Standard time and Daylight Savings time dynamically.

Just a sample:

BEGIN:VTIMEZONE
TZID:America/New_York
LAST-MODIFIED:20050809T050000Z
BEGIN:STANDARD
DTSTART:20071104T020000
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
TZNAME:EST
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:20070311T020000
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
TZNAME:EDT
END:DAYLIGHT
END:VTIMEZONE

In my attempt to solve this I created a moment.tz.zone(timezone)Object which based on the documentation of moment https://momentjs.com/timezone/docs/#/zone-object/ I assume holds the necessary data untils(should be TZOFFSETFROM, TZOFFSETTO) and offsets(DTSTART).

Yet I can't find a clear documentation on how to extract these data.

Was wondering if there's anyway that one can extract the DTSTART, TZOFFSETFROM and TZOFFSETTO for Standard time and Daylight in moment-timezone.js

3 Answers3

4

You can download pre-made VTIMEZONE components here:

http://tzurl.org/

Michael
  • 34,873
  • 17
  • 75
  • 109
  • Note however that there is often a delay between updates of the IANA database and the timezones available here: it is currently 4 versions behind, for instance. It is also note really in sync with the vzic project, so some timezones may not be properly converted :-( – jcaron Jul 09 '19 at 22:37
3

As you already mentioned in the question, you can use the moment.tz.zone(name) method. This will give you a Zone object that contains a list of timestamps in the untils property, then you can apply your logic to get the timestamps you want in the VTIMEZONE (I've used the first timestamps of the untils array in my code sample).

You can use moment.tz and format() on a timestamp to get DTSTART. You can pass ZZ token to format() to get offset for TZOFFSETFROM and TZOFFSETTO.

You can use abbrs values to get TZNAME.

Here a live sample:

const MAX_OCCUR = 2;
const getVtimezoneFromMomentZone = (tzName) => {
  const zone = moment.tz.zone(tzName);
  const header = `BEGIN:VTIMEZONE\nTZID:${tzName}`;
  const footer = 'END:VTIMEZONE';
  
  let zTZitems = '';
  for(let i=0; i<MAX_OCCUR && i+1<zone.untils.length; i++){
    const type = i%2 == 0 ? 'STANDARD' : 'DAYLIGHT';
    const momDtStart = moment.tz(zone.untils[i], tzName);
    const momNext = moment.tz(zone.untils[i+1], tzName);
    const item = 
`BEGIN:${type}
DTSTART:${momDtStart.format('YYYYMMDDTHHmmss')}
TZOFFSETFROM:${momDtStart.format('ZZ')}
TZOFFSETTO:${momNext.format('ZZ')}
TZNAME:${zone.abbrs[i]}
END:${type}\n`;
    zTZitems += item;
  }
  const result = `${header}\n${zTZitems}${footer}\n`;
  return result;
};

console.log(getVtimezoneFromMomentZone('America/New_York'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.23.0/moment-with-locales.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment-timezone/0.5.23/moment-timezone-with-data-2012-2022.min.js"></script>
VincenzoC
  • 30,117
  • 12
  • 90
  • 112
  • After testing a bit found out that this function returns a wrong timezone for Europe/Madrid (haven't been able to figure out why yet) – apnerve Oct 23 '19 at 05:44
  • Your code is assuming `DAYLIGHT` or `STANDARD` based on whether index (`i`) is even or odd index when iterating `zone.untils`. Is there some guarantee of that? Moment's documentation examples show one case with `DAYLIGHT` at index `0` and another example with `STANDARD` at index 0: [here](https://momentjs.com/timezone/docs/#/data-formats/unpacked-format/) and [here](https://momentjs.com/timezone/docs/#/zone-object/). Am I missing something? – mattpr Aug 14 '21 at 14:40
  • It also looks like you are using `TZNAME` from `zone.abbrs[i]` using same index `i` as for `DTSTART` (from `zone.untils[i]`). This looks like it is not correct. **The moment docs say:** The lengths of `abbrs`, `untils`, `offsets` are all the same. The `offset` and `abbr` at any index are only active while the timestamp is **less than** the `until` at that index. An easy way to read this aloud is "between `untils[n-1]` and `untils[n]`, the `abbr` should be `abbrs[n]` and the offset should be `offsets[n]`". – mattpr Aug 14 '21 at 14:50
0

This is a bit challenging to do in a robust way.

Summary

  • Use RRULE to avoid bloating your ics and support long-running or open-ended recurring events.
    • moment-timezone doesn't expose underlying zoneinfo data any way which would make it easy to build RRULE for a given zone (as far as I can tell).
  • For one-off events with fixed date, you can just pick the correct interval(s) to include in the ics from moment.tz.zone('America/New_York').untils based on the event date.

Details

As an example: moment.tz.zone('America/New_York').untils includes 235 intervals (DAYLIGHT or STANDARD over the years) from 1918 to 2037.
You don't want to include them all in your ics.
If you only include the first two in your VTIMEZONE, it won't be valid except for some events in 1918/1919.

var timezoneName = 'America/New_York',
   {untils, abbrs, offsets} = moment.tz.zone(timezone);


console.log(untils.length); 
// 236
console.log(moment.tz(untils[0], timezoneName).format('YYYY-MM-DD HH:mm:ss'));
// 1918-03-31 03:00:00
console.log(moment.tz(untils[untils.length-2], timezoneName).format('YYYY-MM-DD HH:mm:ss')); 
// 2037-11-01 01:00:00
console.log(untils[untils.length-1]);  
// Infinity

You could put all 235 of these intervals into an ICS but it would be really bloated.

The RFC section on VTIMEZONE includes some examples...

  This is an example showing time zone information for New York City
  using only the "DTSTART" property.  Note that this is only
  suitable for a recurring event that starts on or later than March
  11, 2007 at 03:00:00 EDT (i.e., the earliest effective transition
  date and time) and ends no later than March 9, 2008 at 01:59:59 EST (i.e., latest valid date and time for EST in this scenario).
  For example, this can be used for a recurring event that occurs
  every Friday, 8:00 A.M.-9:00 A.M., starting June 1, 2007, ending
  December 31, 2007,

   BEGIN:VTIMEZONE
   TZID:America/New_York
   LAST-MODIFIED:20050809T050000Z
   BEGIN:STANDARD
   DTSTART:20071104T020000
   TZOFFSETFROM:-0400
   TZOFFSETTO:-0500
   TZNAME:EST
   END:STANDARD
   BEGIN:DAYLIGHT
   DTSTART:20070311T020000
   TZOFFSETFROM:-0500
   TZOFFSETTO:-0400
   TZNAME:EDT
   END:DAYLIGHT
   END:VTIMEZONE

The point is that the VTIMEZONE in the example is using only the "DTSTART" property...and in this case the VTIMEZONE is only valid for event dates covered by the STANDARD and DAYLIGHT intervals explicitly listed in the VTIMEZONE.

Another example from the RFC...

  This is a simple example showing the current time zone rules for
  New York City using a "RRULE" recurrence pattern.  Note that there
  is no effective end date to either of the Standard Time or
  Daylight Time rules.  This information would be valid for a
  recurring event starting today and continuing indefinitely.

   BEGIN:VTIMEZONE
   TZID:America/New_York
   LAST-MODIFIED:20050809T050000Z
   TZURL:http://zones.example.com/tz/America-New_York.ics
   BEGIN:STANDARD
   DTSTART:20071104T020000
   RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
   TZOFFSETFROM:-0400
   TZOFFSETTO:-0500
   TZNAME:EST
   END:STANDARD
   BEGIN:DAYLIGHT
   DTSTART:20070311T020000
   RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
   TZOFFSETFROM:-0500
   TZOFFSETTO:-0400
   TZNAME:EDT
   END:DAYLIGHT
   END:VTIMEZONE

Note that in this case the presence of an RRULE which explains when these STANDARD and DAYLIGHT intervals reoccur means that we don't have to explicitly add all the specific intervals over the years. You would just need the most recent (before your event) interval where the RRULE changed. If your event is recurring and spans across rule changes, then you have to include a couple more intervals with corresponding rules to cover the events BEFORE the rule change as well as the events AFTER the rule change.

Indeed, inspecting an ICS generated by Apple's macOS calendar app for an event on August 19, 2021 in timezone Europe/Berlin includes the following VTIMEZONE (indented for readability)...

BEGIN:VTIMEZONE
TZID:Europe/Berlin
    BEGIN:DAYLIGHT
        TZOFFSETFROM:+0100
        RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
        DTSTART:19810329T020000
        TZNAME:GMT+2
        TZOFFSETTO:+0200
    END:DAYLIGHT
    
    BEGIN:STANDARD
        TZOFFSETFROM:+0200
        RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
        DTSTART:19961027T030000
        TZNAME:GMT+1
        TZOFFSETTO:+0100
    END:STANDARD
END:VTIMEZONE

Note that STANDARD has a DTSTART in 1996 and DAYLIGHT has a DTSTART in 1981 despite the event being in 2021. The presence of the RRULE allows them to avoid including many more STANDARD/DAYLIGHT intervals.

Best solution

...is probably to generate RRULE. This allows you to minimize the size of your ics file while supporting recurring events far into the future.

Downside: I can't find any easy way to generate RRULE with moment-timezone... but there appears to be some other libs around that might help (haven't played with them yet).

If anyone has some tips/experience generating RRULEs, it would be great to hear your experience.

Option 2: Work-around for specific use-cases

If you are dynamically generating an ICS file for a single or recurring event where you know the event date (or date range for recurring event), then you can just filter the moment.tz.zone('America/New_York').untils to make sure that you have ALL the STANDARD and DAYLIGHT intervals you need to cover your event date/range.

Downside: for long-running or open-ended recurring events this may not be a good option because too many intervals will have to be included in the ics file (bloat).

However for single, fixed-date events this is probably a fine option.

Quick example for option 2...

I only did a cursory scan of the RFC and to be safe I included the transition FOLLOWING the end date so you will always have at least 2 transitions even when you have an event at a single timestamp. One transition that occurs before the event date and one that occurs after. This may not be necessary.

function generateVTimezone (timezoneName, tsRangeStart, tsRangeEnd) {
    var zone = moment.tz.zone(timezoneName),
        {untils, abbrs, offsets} = zone,
        i, dtStart, utcOffsetBefore, utcOffsetDuring, periodType,
        vtz = [
            `BEGIN:VTIMEZONE`,
            `TZID:${timezoneName}`,
        ];

    tsRangeStart = tsRangeStart || 0;
    tsRangeEnd = tsRangeEnd || Math.pow(2,31)-1;

    // https://momentjs.com/timezone/docs/#/data-formats/unpacked-format/
    // > between `untils[n-1]` and `untils[n]`, the `abbr` should be 
    // > `abbrs[n]` and the `offset` should be `offsets[n]`
    for (i=0; i<untils.length - 1; i++) {
        // filter to intervals that include our start/end range timestamps
        if (untils[i+1] < tsRangeStart) continue; // interval ends before our start, skip
        if (i>0 && untils[i-1] > tsRangeEnd) break; // interval starts after interval we end in, break

        utcOffsetBefore = formatUtcOffset(offsets[i]); // offset BEFORE dtStart
        dtStart = moment.tz(untils[i], timezoneName).format('YYYYMMDDTHHmmss');
        utcOffsetDuring = formatUtcOffset(offsets[i+1]); // offset AFTER dtStart
        periodType = offsets[i+1] < offsets[i] ? 'DAYLIGHT' : 'STANDARD'; // spring-forward, DAYLIGHT, fall-back: STANDARD.
        
        vtz.push(`BEGIN:${periodType}`);
        vtz.push(`DTSTART:${dtStart}`);      // local date-time when change
        vtz.push(`TZOFFSETFROM:${utcOffsetBefore}`); // utc offset BEFORE DTSTART
        vtz.push(`TZOFFSETTO:${utcOffsetDuring}`);   // utc offset AFTER DTSTART
        vtz.push(`TZNAME:${abbrs[i+1]}`);
        vtz.push(`END:${periodType}`);
    }
    vtz.push(`END:VTIMEZONE`);
    return vtz.join('\r\n');  // rfc5545 says CRLF
}

function formatUtcOffset(minutes) {
    var hours = Math.floor(Math.abs(minutes) / 60).toString(),
        mins = (Math.abs(minutes) % 60).toString(),
        sign = minutes > 0 ? '-' : '+', // sign inverted, see https://momentjs.com/timezone/docs/#/zone-object/offset/
        output = [sign];

    // zero-padding
    if (hours.length < 2) output.push('0');
    output.push(hours);
    if (mins.length < 2) output.push('0');
    output.push(mins);

    return output.join('');
}

function test() {
    var timezone = 'America/New_York',
        startTS = moment.tz('2013-11-18 11:55', timezone).unix()*1000,
        endTS = moment.tz('2013-11-18 11:55', timezone).unix()*1000;

    console.log(generateVTimezone(timezone, startTS, endTS));
}

test();

produces output...

BEGIN:VTIMEZONE
TZID:America/New_York
BEGIN:STANDARD
DTSTART:20131103T010000
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
TZNAME:EST
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:20140309T030000
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
TZNAME:EDT
END:DAYLIGHT
END:VTIMEZONE
mattpr
  • 2,504
  • 19
  • 17