From a5604992f2c9e20759048bfde5c7bdc0cb6e447f Mon Sep 17 00:00:00 2001
From: Jennifer Richards <jennifer@painless-security.com>
Date: Fri, 30 Apr 2021 16:14:00 +0000
Subject: [PATCH] Add timezone selector to upcoming meetings page. Separate
 general timezone handling from parts only relevant to main agenda page. Speed
 up agenda timezone javascript tests. Fixes #3184. Commit ready for merge.  -
 Legacy-Id: 18970

---
 ietf/meeting/tests_js.py                      | 267 ++++++++++++---
 ietf/meeting/views.py                         |  12 +-
 ietf/static/ietf/js/agenda/agenda_filter.js   |   1 +
 ietf/static/ietf/js/agenda/agenda_timezone.js | 229 +++++++++++++
 ietf/static/ietf/js/agenda/timezone.js        | 323 ++++--------------
 ietf/templates/meeting/agenda.html            | 107 +++---
 ietf/templates/meeting/upcoming.html          | 191 +++++++++--
 7 files changed, 746 insertions(+), 384 deletions(-)
 create mode 100644 ietf/static/ietf/js/agenda/agenda_timezone.js

diff --git a/ietf/meeting/tests_js.py b/ietf/meeting/tests_js.py
index 13fec4bc3..95e7b9a35 100644
--- a/ietf/meeting/tests_js.py
+++ b/ietf/meeting/tests_js.py
@@ -13,7 +13,8 @@ import django
 #from django.urls import reverse as urlreverse
 from django.utils.text import slugify
 from django.db.models import F
-from pytz import timezone
+import pytz
+
 #from django.test.utils import override_settings
 
 import debug                            # pyflakes:ignore
@@ -37,7 +38,7 @@ from ietf import settings
 if selenium_enabled():
     from selenium.webdriver.common.action_chains import ActionChains
     from selenium.webdriver.common.by import By
-    from selenium.webdriver.support.ui import Select, WebDriverWait
+    from selenium.webdriver.support.ui import WebDriverWait
     from selenium.webdriver.support import expected_conditions
     from selenium.common.exceptions import NoSuchElementException
 
@@ -957,25 +958,22 @@ class AgendaTests(IetfSeleniumTestCase):
             with self.assertRaises(NoSuchElementException):
                 self.driver.find_element_by_xpath('//a[text()="%s"]' % slide.title)
 
-    def _wait_for_tz_change_from(self, old_tz):
-        """Helper to wait for tz displays to change from their old value"""
-        match = 'text()!="%s"' % old_tz
-        WebDriverWait(self.driver, 2).until(
-            expected_conditions.presence_of_element_located((By.XPATH, '//*[@class="current-tz"][%s]' % match))
-        )
-
     def test_agenda_time_zone_selection(self):
         self.assertNotEqual(self.meeting.time_zone, 'UTC', 'Meeting time zone must not be UTC')
 
+        wait = WebDriverWait(self.driver, 2)
         self.driver.get(self.absreverse('ietf.meeting.views.agenda'))
 
         # wait for the select box to be updated - look for an arbitrary time zone to be in
         # its options list to detect this
-        WebDriverWait(self.driver, 2).until(
-            expected_conditions.presence_of_element_located((By.XPATH, '//option[@value="America/Halifax"]'))
+        arbitrary_tz = 'America/Halifax'
+        arbitrary_tz_opt = WebDriverWait(self.driver, 2).until(
+            expected_conditions.presence_of_element_located(
+                (By.CSS_SELECTOR, '#timezone-select > option[value="%s"]' % arbitrary_tz)
+            )
         )
 
-        tz_select_input = Select(self.driver.find_element_by_id('timezone_select'))
+        tz_select_input = self.driver.find_element_by_id('timezone-select')
         meeting_tz_link = self.driver.find_element_by_id('meeting-timezone')
         local_tz_link = self.driver.find_element_by_id('local-timezone')
         utc_tz_link = self.driver.find_element_by_id('utc-timezone')
@@ -991,73 +989,110 @@ class AgendaTests(IetfSeleniumTestCase):
         self.assertNotEqual(self.meeting.time_zone, local_tz, 'Meeting time zone must not be local time zone')
         self.assertNotEqual(local_tz, 'UTC', 'Local time zone must not be UTC')
 
+        meeting_tz_opt = tz_select_input.find_element_by_css_selector('option[value="%s"]' % self.meeting.time_zone)
+        local_tz_opt = tz_select_input.find_element_by_css_selector('option[value="%s"]' % local_tz)
+        utc_tz_opt = tz_select_input.find_element_by_css_selector('option[value="UTC"]')
+
         # Should start off in meeting time zone
-        self.assertEqual(tz_select_input.first_selected_option.get_attribute('value'), self.meeting.time_zone)
+        self.assertTrue(meeting_tz_opt.is_selected())
+        # don't yet know local_tz, so can't check that it's deselected here
+        self.assertFalse(arbitrary_tz_opt.is_selected())
+        self.assertFalse(utc_tz_opt.is_selected())
         for disp in tz_displays:
             self.assertEqual(disp.text.strip(), self.meeting.time_zone)
 
         # Click 'local' button
         local_tz_link.click()
-        self._wait_for_tz_change_from(self.meeting.time_zone)
-        self.assertEqual(tz_select_input.first_selected_option.get_attribute('value'), local_tz)
+        wait.until(expected_conditions.element_selection_state_to_be(meeting_tz_opt, False))
+        self.assertFalse(meeting_tz_opt.is_selected())
+        # just identified the local_tz_opt as being selected, so no check here, either
+        self.assertFalse(arbitrary_tz_opt.is_selected())
+        self.assertFalse(utc_tz_opt.is_selected())
         for disp in tz_displays:
             self.assertEqual(disp.text.strip(), local_tz)
 
         # click 'utc' button
         utc_tz_link.click()
-        self._wait_for_tz_change_from(local_tz)
-        self.assertEqual(tz_select_input.first_selected_option.get_attribute('value'), 'UTC')
+        wait.until(expected_conditions.element_to_be_selected(utc_tz_opt))
+        self.assertFalse(meeting_tz_opt.is_selected())
+        self.assertFalse(local_tz_opt.is_selected())  # finally!
+        self.assertFalse(arbitrary_tz_opt.is_selected())
+        self.assertTrue(utc_tz_opt.is_selected())
         for disp in tz_displays:
             self.assertEqual(disp.text.strip(), 'UTC')
 
         # click back to meeting
         meeting_tz_link.click()
-        self._wait_for_tz_change_from('UTC')
-        self.assertEqual(tz_select_input.first_selected_option.get_attribute('value'), self.meeting.time_zone)
+        wait.until(expected_conditions.element_to_be_selected(meeting_tz_opt))
+        self.assertTrue(meeting_tz_opt.is_selected())
+        self.assertFalse(local_tz_opt.is_selected())
+        self.assertFalse(arbitrary_tz_opt.is_selected())
+        self.assertFalse(utc_tz_opt.is_selected())
         for disp in tz_displays:
             self.assertEqual(disp.text.strip(), self.meeting.time_zone)
 
         # and then back to UTC...
         utc_tz_link.click()
-        self._wait_for_tz_change_from(self.meeting.time_zone)
-        self.assertEqual(tz_select_input.first_selected_option.get_attribute('value'), 'UTC')
+        wait.until(expected_conditions.element_to_be_selected(utc_tz_opt))
+        self.assertFalse(meeting_tz_opt.is_selected())
+        self.assertFalse(local_tz_opt.is_selected())
+        self.assertFalse(arbitrary_tz_opt.is_selected())
+        self.assertTrue(utc_tz_opt.is_selected())
         for disp in tz_displays:
             self.assertEqual(disp.text.strip(), 'UTC')
 
         # ... and test the switch from UTC to local
         local_tz_link.click()
-        self._wait_for_tz_change_from('UTC')
-        self.assertEqual(tz_select_input.first_selected_option.get_attribute('value'), local_tz)
+        wait.until(expected_conditions.element_to_be_selected(local_tz_opt))
+        self.assertFalse(meeting_tz_opt.is_selected())
+        self.assertTrue(local_tz_opt.is_selected())
+        self.assertFalse(arbitrary_tz_opt.is_selected())
+        self.assertFalse(utc_tz_opt.is_selected())
         for disp in tz_displays:
             self.assertEqual(disp.text.strip(), local_tz)
 
         # Now select a different item from the select input
-        tz_select_input.select_by_value('America/Halifax')
-        self._wait_for_tz_change_from(self.meeting.time_zone)
-        self.assertEqual(tz_select_input.first_selected_option.get_attribute('value'), 'America/Halifax')
+        arbitrary_tz_opt.click()
+        wait.until(expected_conditions.element_to_be_selected(arbitrary_tz_opt))
+        self.assertFalse(meeting_tz_opt.is_selected())
+        self.assertFalse(local_tz_opt.is_selected())
+        self.assertTrue(arbitrary_tz_opt.is_selected())
+        self.assertFalse(utc_tz_opt.is_selected())
         for disp in tz_displays:
-            self.assertEqual(disp.text.strip(), 'America/Halifax')
-
+            self.assertEqual(disp.text.strip(), arbitrary_tz)
+    
     def test_agenda_time_zone_selection_updates_weekview(self):
         """Changing the time zone should update the weekview to match"""
+        class in_iframe_href:
+            """Condition class for use with WebDriverWait"""
+            def __init__(self, fragment, iframe):
+                self.fragment = fragment
+                self.iframe = iframe
+
+            def __call__(self, driver):
+                driver.switch_to.frame(self.iframe)
+                current_href= driver.execute_script(
+                    'return document.location.href'
+                )
+                driver.switch_to.default_content()
+                return self.fragment in current_href
+            
         # enable a filter so the weekview iframe is visible
         self.driver.get(self.absreverse('ietf.meeting.views.agenda') + '?show=mars')
         # wait for the select box to be updated - look for an arbitrary time zone to be in
         # its options list to detect this
-        WebDriverWait(self.driver, 2).until(
-            expected_conditions.presence_of_element_located((By.XPATH, '//option[@value="America/Halifax"]'))
+        wait = WebDriverWait(self.driver, 2)
+        option = wait.until(
+            expected_conditions.presence_of_element_located(
+                (By.CSS_SELECTOR, '#timezone-select > option[value="America/Halifax"]'))
         )
-
-        tz_select_input = Select(self.driver.find_element_by_id('timezone_select'))
-
         # Now select a different item from the select input
-        tz_select_input.select_by_value('America/Halifax')
-        self._wait_for_tz_change_from(self.meeting.time_zone)
-        self.assertEqual(tz_select_input.first_selected_option.get_attribute('value'), 'America/Halifax')
-        self.driver.switch_to.frame('weekview')
-        wv_url = self.driver.execute_script('return document.location.href')
-        self.assertIn('tz=america/halifax', wv_url)
-
+        option.click()
+        try:
+            wait.until(in_iframe_href('tz=america/halifax', 'weekview'))
+        except:
+            self.fail('iframe href not updated to contain selected time zone')
+        
 
 @ifSeleniumEnabled
 class WeekviewTests(IetfSeleniumTestCase):
@@ -1099,7 +1134,7 @@ class WeekviewTests(IetfSeleniumTestCase):
         zones_to_test = ['utc', 'America/Halifax', 'Asia/Bangkok', 'Africa/Dakar', 'Europe/Dublin']
         self.login()
         for zone_name in zones_to_test:
-            zone = timezone(zone_name)
+            zone = pytz.timezone(zone_name)
             self.driver.get(self.absreverse('ietf.meeting.views.week_view') + '?tz=' + zone_name)
             for item in self.get_expected_items():
                 if item.session.name:
@@ -1155,10 +1190,10 @@ class WeekviewTests(IetfSeleniumTestCase):
 
         # Session during a single day in meeting local time but multi-day UTC
         # Compute a time that overlaps midnight, UTC, but won't when shifted to a local time zone
-        start_time_utc = timezone('UTC').localize(
+        start_time_utc = pytz.timezone('UTC').localize(
             datetime.datetime.combine(self.meeting.date, datetime.time(23,0))
         )
-        start_time_local = start_time_utc.astimezone(timezone(self.meeting.time_zone))
+        start_time_local = start_time_utc.astimezone(pytz.timezone(self.meeting.time_zone))
 
         daytime_session = SessionFactory(
             meeting=self.meeting,
@@ -1551,6 +1586,152 @@ class InterimTests(IetfSeleniumTestCase):
         meetings.update(self.displayed_interims(groups=['mars']))
         self.do_upcoming_view_filter_test('?show=mars , ames &hide=   ames', meetings)
 
+    def test_upcoming_view_time_zone_selection(self):
+        wait = WebDriverWait(self.driver, 2)
+
+        def _assert_interim_tz_correct(sessions, tz):
+            zone = pytz.timezone(tz)
+            for session in sessions:
+                ts = session.official_timeslotassignment().timeslot
+                start = ts.utc_start_time().astimezone(zone).strftime('%Y-%m-%d %H:%M')
+                end = ts.utc_end_time().astimezone(zone).strftime('%H:%M')
+                meeting_link = self.driver.find_element_by_link_text(session.meeting.number)
+                time_td = meeting_link.find_element_by_xpath('../../td[@class="session-time"]')
+                self.assertIn('%s - %s' % (start, end), time_td.text)
+
+        def _assert_ietf_tz_correct(meetings, tz):
+            zone = pytz.timezone(tz)
+            for meeting in meetings:
+                meeting_zone = pytz.timezone(meeting.time_zone)
+                start_dt = meeting_zone.localize(datetime.datetime.combine(
+                    meeting.date, 
+                    datetime.time.min
+                ))
+                end_dt = meeting_zone.localize(datetime.datetime.combine(
+                    start_dt + datetime.timedelta(days=meeting.days - 1),
+                    datetime.time.max
+                ))
+                
+                start = start_dt.astimezone(zone).strftime('%Y-%m-%d')
+                end = end_dt.astimezone(zone).strftime('%Y-%m-%d')
+                meeting_link = self.driver.find_element_by_link_text("IETF " + meeting.number)
+                time_td = meeting_link.find_element_by_xpath('../../td[@class="meeting-time"]')
+                self.assertIn('%s - %s' % (start, end), time_td.text)
+
+        sessions = [m.session_set.first() for m in self.displayed_interims()]
+        self.assertGreater(len(sessions), 0)
+        ietf_meetings = self.all_ietf_meetings()
+        self.assertGreater(len(ietf_meetings), 0)
+
+        self.driver.get(self.absreverse('ietf.meeting.views.upcoming'))
+        tz_select_input = self.driver.find_element_by_id('timezone-select')
+        tz_select_bottom_input = self.driver.find_element_by_id('timezone-select-bottom')
+        local_tz_link = self.driver.find_element_by_id('local-timezone')
+        utc_tz_link = self.driver.find_element_by_id('utc-timezone')
+        local_tz_bottom_link = self.driver.find_element_by_id('local-timezone-bottom')
+        utc_tz_bottom_link = self.driver.find_element_by_id('utc-timezone-bottom')
+        
+        # wait for the select box to be updated - look for an arbitrary time zone to be in
+        # its options list to detect this
+        arbitrary_tz = 'America/Halifax'
+        arbitrary_tz_opt = wait.until(
+            expected_conditions.presence_of_element_located(
+                (By.CSS_SELECTOR, '#timezone-select > option[value="%s"]' % arbitrary_tz)
+            )
+        )
+        arbitrary_tz_bottom_opt = tz_select_bottom_input.find_element_by_css_selector(
+            'option[value="%s"]' % arbitrary_tz)
+
+        utc_tz_opt = tz_select_input.find_element_by_css_selector('option[value="UTC"]')
+        utc_tz_bottom_opt= tz_select_bottom_input.find_element_by_css_selector('option[value="UTC"]')
+
+        # Moment.js guesses local time zone based on the behavior of Selenium's web client. This seems
+        # to inherit Django's settings.TIME_ZONE but I don't know whether that's guaranteed to be consistent.
+        # To avoid test fragility, ask Moment what it considers local and expect that.
+        local_tz = self.driver.execute_script('return moment.tz.guess();')
+        local_tz_opt = tz_select_input.find_element_by_css_selector('option[value=%s]' % local_tz)
+        local_tz_bottom_opt = tz_select_bottom_input.find_element_by_css_selector('option[value="%s"]' % local_tz)
+
+        # Should start off in local time zone
+        self.assertTrue(local_tz_opt.is_selected())
+        self.assertTrue(local_tz_bottom_opt.is_selected())
+        _assert_interim_tz_correct(sessions, local_tz)
+        _assert_ietf_tz_correct(ietf_meetings, local_tz)
+
+        # click 'utc' button
+        utc_tz_link.click()
+        wait.until(expected_conditions.element_to_be_selected(utc_tz_opt))
+        self.assertFalse(local_tz_opt.is_selected())
+        self.assertFalse(local_tz_bottom_opt.is_selected())
+        self.assertFalse(arbitrary_tz_opt.is_selected())
+        self.assertFalse(arbitrary_tz_bottom_opt.is_selected())
+        self.assertTrue(utc_tz_opt.is_selected())
+        self.assertTrue(utc_tz_bottom_opt.is_selected())
+        _assert_interim_tz_correct(sessions, 'UTC')
+        _assert_ietf_tz_correct(ietf_meetings, 'UTC')
+
+        # click back to 'local'
+        local_tz_link.click()
+        wait.until(expected_conditions.element_to_be_selected(local_tz_opt))
+        self.assertTrue(local_tz_opt.is_selected())
+        self.assertTrue(local_tz_bottom_opt.is_selected())
+        self.assertFalse(arbitrary_tz_opt.is_selected())
+        self.assertFalse(arbitrary_tz_bottom_opt.is_selected())
+        self.assertFalse(utc_tz_opt.is_selected())
+        self.assertFalse(utc_tz_bottom_opt.is_selected())
+        _assert_interim_tz_correct(sessions, local_tz)
+        _assert_ietf_tz_correct(ietf_meetings, local_tz)
+
+        # Now select a different item from the select input
+        arbitrary_tz_opt.click()
+        wait.until(expected_conditions.element_to_be_selected(arbitrary_tz_opt))
+        self.assertFalse(local_tz_opt.is_selected())
+        self.assertFalse(local_tz_bottom_opt.is_selected())
+        self.assertTrue(arbitrary_tz_opt.is_selected())
+        self.assertTrue(arbitrary_tz_bottom_opt.is_selected())
+        self.assertFalse(utc_tz_opt.is_selected())
+        self.assertFalse(utc_tz_bottom_opt.is_selected())
+        _assert_interim_tz_correct(sessions, arbitrary_tz)
+        _assert_ietf_tz_correct(ietf_meetings, arbitrary_tz)
+
+        # Now repeat those tests using the widgets at the bottom of the page
+        # click 'utc' button
+        utc_tz_bottom_link.click()
+        wait.until(expected_conditions.element_to_be_selected(utc_tz_opt))
+        self.assertFalse(local_tz_opt.is_selected())
+        self.assertFalse(local_tz_bottom_opt.is_selected())
+        self.assertFalse(arbitrary_tz_opt.is_selected())
+        self.assertFalse(arbitrary_tz_bottom_opt.is_selected())
+        self.assertTrue(utc_tz_opt.is_selected())
+        self.assertTrue(utc_tz_bottom_opt.is_selected())
+        _assert_interim_tz_correct(sessions, 'UTC')
+        _assert_ietf_tz_correct(ietf_meetings, 'UTC')
+
+        # click back to 'local'
+        local_tz_bottom_link.click()
+        wait.until(expected_conditions.element_to_be_selected(local_tz_opt))
+        self.assertTrue(local_tz_opt.is_selected())
+        self.assertTrue(local_tz_bottom_opt.is_selected())
+        self.assertFalse(arbitrary_tz_opt.is_selected())
+        self.assertFalse(arbitrary_tz_bottom_opt.is_selected())
+        self.assertFalse(utc_tz_opt.is_selected())
+        self.assertFalse(utc_tz_bottom_opt.is_selected())
+        _assert_interim_tz_correct(sessions, local_tz)
+        _assert_ietf_tz_correct(ietf_meetings, local_tz)
+
+        # Now select a different item from the select input
+        arbitrary_tz_bottom_opt.click()
+        wait.until(expected_conditions.element_to_be_selected(arbitrary_tz_opt))
+        self.assertFalse(local_tz_opt.is_selected())
+        self.assertFalse(local_tz_bottom_opt.is_selected())
+        self.assertTrue(arbitrary_tz_opt.is_selected())
+        self.assertTrue(arbitrary_tz_bottom_opt.is_selected())
+        self.assertFalse(utc_tz_opt.is_selected())
+        self.assertFalse(utc_tz_bottom_opt.is_selected())
+        _assert_interim_tz_correct(sessions, arbitrary_tz)
+        _assert_ietf_tz_correct(ietf_meetings, arbitrary_tz)
+
+
 # The following are useful debugging tools
 
 # If you add this to a LiveServerTestCase and run just this test, you can browse
diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py
index d3d3edc09..0b5e662da 100644
--- a/ietf/meeting/views.py
+++ b/ietf/meeting/views.py
@@ -3363,7 +3363,7 @@ def upcoming(request):
     # Get ietf meetings starting 7 days ago, and interim meetings starting today
     ietf_meetings = Meeting.objects.filter(type_id='ietf', date__gte=today-datetime.timedelta(days=7))
     for m in ietf_meetings:
-        m.end = m.date+datetime.timedelta(days=m.days)
+        m.end = m.date + datetime.timedelta(days=m.days-1)  # subtract 1 to avoid counting an extra day
 
     interim_sessions = add_event_info_to_session_qs(
         Session.objects.filter(
@@ -3411,13 +3411,11 @@ def upcoming(request):
     
     for o in entries:
         if isinstance(o, Meeting):
-            o.start_timestamp = int(pytz.utc.localize(datetime.datetime.combine(o.date, datetime.datetime.min.time())).timestamp())
-            o.end_timestamp = int(pytz.utc.localize(datetime.datetime.combine(o.end, datetime.datetime.max.time())).timestamp())
+            o.start_timestamp = int(pytz.utc.localize(datetime.datetime.combine(o.date, datetime.time.min)).timestamp())
+            o.end_timestamp = int(pytz.utc.localize(datetime.datetime.combine(o.end, datetime.time.max)).timestamp())
         else:
-            o.start_timestamp = int(o.official_timeslotassignment().
-                               timeslot.utc_start_time().timestamp());
-            o.end_timestamp = int(o.official_timeslotassignment().
-                               timeslot.utc_end_time().timestamp());
+            o.start_timestamp = int(o.official_timeslotassignment().timeslot.utc_start_time().timestamp())
+            o.end_timestamp = int(o.official_timeslotassignment().timeslot.utc_end_time().timestamp())
 
     # add menu entries
     menu_entries = get_interim_menu_entries(request)
diff --git a/ietf/static/ietf/js/agenda/agenda_filter.js b/ietf/static/ietf/js/agenda/agenda_filter.js
index 9840303df..58799390b 100644
--- a/ietf/static/ietf/js/agenda/agenda_filter.js
+++ b/ietf/static/ietf/js/agenda/agenda_filter.js
@@ -232,6 +232,7 @@ var agenda_filter_for_testing; // methods to be accessed for automated testing
      * button UI. Do not call if you only want to use the parameter parsing routines.
      */
     function enable () {
+        // ready handler fires immediately if document is already "ready"
         $(document).ready(function () {
             register_handlers();
             update_view();
diff --git a/ietf/static/ietf/js/agenda/agenda_timezone.js b/ietf/static/ietf/js/agenda/agenda_timezone.js
new file mode 100644
index 000000000..1d05a65d2
--- /dev/null
+++ b/ietf/static/ietf/js/agenda/agenda_timezone.js
@@ -0,0 +1,229 @@
+// Copyright The IETF Trust 2021, All Rights Reserved
+
+/*
+ Timezone support specific to the agenda page
+
+ To properly handle timezones other than local, needs a method to retrieve
+ the current timezone. Set this by passing a method taking no parameters and
+ returning the current timezone to the set_current_tz_cb() method.
+ This should be done before calling anything else in the file.
+ */
+
+var meeting_timezone;
+var local_timezone = moment.tz.guess();
+
+// get_current_tz_cb must be overwritten using set_current_tz_cb
+var get_current_tz_cb = function () {
+    throw new Error('Tried to get current timezone before callback registered. Use set_current_tz_cb().')
+};
+
+// Initialize moments
+function initialize_moments() {
+    var times=$('span.time')
+    $.each(times, function(i, item) {
+        item.start_ts = moment.unix(this.getAttribute("data-start-time")).utc();
+        item.end_ts = moment.unix(this.getAttribute("data-end-time")).utc();
+        if (this.hasAttribute("weekday")) {
+            item.format=2;
+        } else {
+            item.format=1;
+        }
+        if (this.hasAttribute("format")) {
+            item.format = +this.getAttribute("format");
+        }
+    });
+    var times=$('[data-slot-start-ts]')
+    $.each(times, function(i, item) {
+        item.slot_start_ts = moment.unix(this.getAttribute("data-slot-start-ts")).utc();
+        item.slot_end_ts = moment.unix(this.getAttribute("data-slot-end-ts")).utc();
+    });
+}
+
+function format_time(t, tz, fmt) {
+    var out;
+    var mtz = meeting_timezone;
+    if (mtz == "") {
+        mtz = "UTC";
+    }
+
+    switch (fmt) {
+        case 0:
+            out = t.tz(tz).format('dddd, ') + '<span class="hidden-xs">' +
+                t.tz(tz).format('MMMM Do YYYY, ') + '</span>' +
+                t.tz(tz).format('HH:mm') + '<span class="hidden-xs">' +
+                t.tz(tz).format(' Z z') + '</span>';
+            break;
+        case 1:
+            // Note, this code does not work if the meeting crosses the
+            // year boundary.
+            out = t.tz(tz).format("HH:mm");
+            if (+t.tz(tz).dayOfYear() < +t.tz(mtz).dayOfYear()) {
+                out = out + " (-1)";
+            } else if (+t.tz(tz).dayOfYear() > +t.tz(mtz).dayOfYear()) {
+                out = out + " (+1)";
+            }
+            break;
+        case 2:
+            out = t.tz(mtz).format("dddd, ").toUpperCase() +
+                t.tz(tz).format("HH:mm");
+            if (+t.tz(tz).dayOfYear() < +t.tz(mtz).dayOfYear()) {
+                out = out + " (-1)";
+            } else if (+t.tz(tz).dayOfYear() > +t.tz(mtz).dayOfYear()) {
+                out = out + " (+1)";
+            }
+            break;
+        case 3:
+            out = t.utc().format("YYYY-MM-DD");
+            break;
+        case 4:
+            out = t.tz(tz).format("YYYY-MM-DD HH:mm");
+            break;
+        case 5:
+            out = t.tz(tz).format("HH:mm");
+            break;
+    }
+    return out;
+}
+
+
+// Format tooltip notice
+function format_tooltip_notice(start, end) {
+    var notice = "";
+
+    if (end.isBefore()) {
+        notice = "Event ended " + end.fromNow();
+    } else if (start.isAfter()) {
+        notice = "Event will start " + start.fromNow();
+    } else {
+        notice = "Event started " + start.fromNow() + " and will end " +
+            end.fromNow();
+    }
+    return '<span class="tooltipnotice">' + notice + '</span>';
+}
+
+// Format tooltip table
+function format_tooltip_table(start, end) {
+    var current_timezone = get_current_tz_cb();
+    var out = '<table><tr><th>Timezone</th><th>Start</th><th>End</th></tr>';
+    if (meeting_timezone !== "") {
+        out += '<tr><td class="timehead">Meeting timezone:</td><td>' +
+            format_time(start, meeting_timezone, 0) + '</td><td>' +
+            format_time(end, meeting_timezone, 0) + '</td></tr>';
+    }
+    out += '<tr><td class="timehead">Local timezone:</td><td>' +
+        format_time(start, local_timezone, 0) + '</td><td>' +
+        format_time(end, local_timezone, 0) + '</td></tr>';
+    if (current_timezone !== 'UTC') {
+        out += '<tr><td class="timehead">Selected Timezone:</td><td>' +
+            format_time(start, current_timezone, 0) + '</td><td>' +
+            format_time(end, current_timezone, 0) + '</td></tr>';
+    }
+    out += '<tr><td class="timehead">UTC:</td><td>' +
+        format_time(start, 'UTC', 0) + '</td><td>' +
+        format_time(end, 'UTC', 0) + '</td></tr>';
+    out += '</table>' + format_tooltip_notice(start, end);
+    return out;
+}
+
+// Format tooltip for item
+function format_tooltip(start, end) {
+    return '<span class="timetooltiptext">' +
+        format_tooltip_table(start, end) +
+        '</span>';
+}
+
+// Add tooltips
+function add_tooltips() {
+    $('span.time').each(function () {
+        var tooltip = $(format_tooltip(this.start_ts, this.end_ts));
+        tooltip[0].start_ts = this.start_ts;
+        tooltip[0].end_ts = this.end_ts;
+        tooltip[0].ustart_ts = moment(this.start_ts).add(-2, 'hours');
+        tooltip[0].uend_ts = moment(this.end_ts).add(2, 'hours');
+        $(this).parent().append(tooltip);
+    });
+}
+
+// Update times on the agenda based on the selected timezone
+function update_times(newtz) {
+    $('span.current-tz').html(newtz);
+    $('span.time').each(function () {
+        if (this.format == 4) {
+            var tz = this.start_ts.tz(newtz).format(" z");
+            if (this.start_ts.tz(newtz).dayOfYear() ==
+                this.end_ts.tz(newtz).dayOfYear()) {
+                $(this).html(format_time(this.start_ts, newtz, this.format) +
+                    '-' + format_time(this.end_ts, newtz, 5) + tz);
+            } else {
+                $(this).html(format_time(this.start_ts, newtz, this.format) +
+                    '-' +
+                    format_time(this.end_ts, newtz, this.format) + tz);
+            }
+        } else {
+            $(this).html(format_time(this.start_ts, newtz, this.format) + '-' +
+                format_time(this.end_ts, newtz, this.format));
+        }
+    });
+    update_tooltips_all();
+    update_clock();
+}
+
+// Highlight ongoing based on the current time
+function highlight_ongoing() {
+    $("div#now").remove("#now");
+    $('.ongoing').removeClass("ongoing");
+    var agenda_rows=$('[data-slot-start-ts]')
+    agenda_rows = agenda_rows.filter(function() {
+        return moment().isBetween(this.slot_start_ts, this.slot_end_ts);
+    });
+    agenda_rows.addClass("ongoing");
+    agenda_rows.first().children("th, td").
+    prepend($('<div id="now" class="anchor-target"></div>'));
+}
+
+// Update tooltips
+function update_tooltips() {
+    var tooltips=$('.timetooltiptext');
+    tooltips.filter(function() {
+        return moment().isBetween(this.ustart_ts, this.uend_ts);
+    }).each(function () {
+        $(this).html(format_tooltip_table(this.start_ts, this.end_ts));
+    });
+}
+
+// Update all tooltips
+function update_tooltips_all() {
+    var tooltips=$('.timetooltiptext');
+    tooltips.each(function () {
+        $(this).html(format_tooltip_table(this.start_ts, this.end_ts));
+    });
+}
+
+// Update clock
+function update_clock() {
+    $('#current-time').html(format_time(moment(), get_current_tz_cb(), 0));
+}
+
+$.urlParam = function(name) {
+    var results = new RegExp('[\?&]' + name + '=([^&#]*)').exec(window.location.href);
+    if (results == null) {
+        return null;
+    } else {
+        return results[1] || 0;
+    }
+}
+
+function init_timers() {
+    var fast_timer = 60000 / (speedup > 600 ? 600 : speedup);
+    update_clock();
+    highlight_ongoing();
+    setInterval(function() { update_clock(); }, fast_timer);
+    setInterval(function() { highlight_ongoing(); }, fast_timer);
+    setInterval(function() { update_tooltips(); }, fast_timer);
+    setInterval(function() { update_tooltips_all(); }, 3600000 / speedup);
+}
+
+// set method used to find current time zone
+function set_current_tz_cb(fn) {
+    get_current_tz_cb = fn;
+}
\ No newline at end of file
diff --git a/ietf/static/ietf/js/agenda/timezone.js b/ietf/static/ietf/js/agenda/timezone.js
index 61e271e53..c282185a7 100644
--- a/ietf/static/ietf/js/agenda/timezone.js
+++ b/ietf/static/ietf/js/agenda/timezone.js
@@ -1,265 +1,74 @@
-// Callback for timezone change - called after current_timezone is updated
-var timezone_change_callback;
+// Copyright The IETF Trust 2021, All Rights Reserved
 
-// Initialize moments
-function initialize_moments() {
-    var times=$('span.time')
-    $.each(times, function(i, item) {
-	item.start_ts = moment.unix(this.getAttribute("data-start-time")).utc();
-	item.end_ts = moment.unix(this.getAttribute("data-end-time")).utc();
-	if (this.hasAttribute("weekday")) {
-            item.format=2;
-	} else {
-            item.format=1;
-	}
-	if (this.hasAttribute("format")) {
-	    item.format = +this.getAttribute("format");
-	}
-    });
-    var times=$('[data-slot-start-ts]')
-    $.each(times, function(i, item) {
-	item.slot_start_ts = moment.unix(this.getAttribute("data-slot-start-ts")).utc();
-	item.slot_end_ts = moment.unix(this.getAttribute("data-slot-end-ts")).utc();
-    });
-}
+/*
+ Timezone selection handling. Relies on the moment.js library.
 
-// Initialize timezone system
-function timezone_init(current) {
-    var tz_names = moment.tz.names();
-    var select = $('#timezone_select');
-    
-    select.empty();
-    $.each(tz_names, function(i, item) {
-	if (current === item) {
-            select.append($('<option/>', {
-		selected: "selected", html: item, value: item }));
-	} else {
-            select.append($('<option/>', {
-		html: item, value: item }));
-	}
-    });
-    initialize_moments();
-    select.change(function () {
-	update_times(this.value);
-    });
-    update_times(current);
-    add_tooltips();
-}
+ To use, create one (or more) select inputs with class "tz-select". When the initialize()
+ method is called, the options in the select will be replaced with the recognized time zone
+ names. Time zone can be changed via the select input or by calling the use() method with
+ the name of a time zone (or 'local' to guess the user's local timezone).
+ */
+var ietf_timezone; // public interface
 
-// Select which timezone is used, 0 = meeting, 1 = browser local, 2 = UTC
-function use_timezone (val) {
-    switch (val) {
-    case 0:
-	tz = meeting_timezone;
-	break;
-    case 1:
-	tz = local_timezone;
-	break;       
-    default:
-	tz = 'UTC';
-	break;
+(function () {
+    'use strict';
+    // Callback for timezone change - called after current_timezone is updated
+    var timezone_change_callback;
+    var current_timezone;
+
+    // Select timezone to use. Arg is name of a timezone or 'local' to guess local tz.
+    function use_timezone (newtz) {
+        // Guess local timezone if necessary
+        if (newtz.toLowerCase() === 'local') {
+            newtz = moment.tz.guess()
+        }
+
+        if (current_timezone !== newtz) {
+            current_timezone = newtz
+            // Update values of tz-select inputs but do not trigger change event
+            $('select.tz-select').val(newtz)
+            if (timezone_change_callback) {
+                timezone_change_callback(newtz)
+            }
+        }
     }
-    $('#timezone_select').val(tz);
-    update_times(tz);
-}
 
-// Format time for item for timezone. Depending on the fmt
-// use different formats.
-// Formats: 0 = long format "Saturday, October 24th 2020, 13:52 +00:00 UTC"
-//          1 = Short format "13:52", "13:52 (-1)", or "13:52 (+1)"
-//          2 = Short format with weekday, "Friday, 13:52 (-1)"
-//          3 = Date only "2020-10-24"
-//          4 = Date and time "2020-10-24 13:52"
-//          5 = Time only "13:52".
+    /* Initialize timezone system
+     *
+     * This will set the timezone to the value of 'current'. Set up the tz_change callback
+     * before initializing.
+     */
+    function timezone_init (current) {
+        var tz_names = moment.tz.names()
+        var select = $('select.tz-select')
 
-function format_time(t, tz, fmt) {
-    var out;
-    var mtz = meeting_timezone;
-    if (mtz == "") {
-	mtz = "UTC";
+        select.empty()
+        $.each(tz_names, function (i, item) {
+            if (current === item) {
+                select.append($('<option/>', {
+                    selected: 'selected', html: item, value: item
+                }))
+            } else {
+                select.append($('<option/>', {
+                    html: item, value: item
+                }))
+            }
+        })
+        select.change(function () {use_timezone(this.value)});
+        /* When navigating back/forward, the browser may change the select input's
+         * value after the window load event. It does not fire the change event on
+         * the input when it does this. The pageshow event occurs after such an update,
+         * so trigger the change event ourselves to be sure the UI stays consistent
+         * with the timezone select input. */
+        window.addEventListener('pageshow', function(){select.change()})
+        use_timezone(current);
     }
-    
-    switch (fmt) {
-    case 0:
-	out = t.tz(tz).format('dddd, ') + '<span class="hidden-xs">' +
-	    t.tz(tz).format('MMMM Do YYYY, ') + '</span>' +
-	    t.tz(tz).format('HH:mm') + '<span class="hidden-xs">' +
-	    t.tz(tz).format(' Z z') + '</span>';
-	break;
-    case 1:
-	// Note, this code does not work if the meeting crosses the
-	// year boundary.
-	out = t.tz(tz).format("HH:mm");
-	if (+t.tz(tz).dayOfYear() < +t.tz(mtz).dayOfYear()) {
-            out = out + " (-1)";
-	} else if (+t.tz(tz).dayOfYear() > +t.tz(mtz).dayOfYear()) {
-            out = out + " (+1)";
-	}
-	break;
-    case 2:
-	out = t.tz(mtz).format("dddd, ").toUpperCase() +
-            t.tz(tz).format("HH:mm");
-	if (+t.tz(tz).dayOfYear() < +t.tz(mtz).dayOfYear()) {
-            out = out + " (-1)";
-	} else if (+t.tz(tz).dayOfYear() > +t.tz(mtz).dayOfYear()) {
-            out = out + " (+1)";
-	}
-	break;
-    case 3:
-	out = t.utc().format("YYYY-MM-DD");
-	break;
-    case 4:
-	out = t.tz(tz).format("YYYY-MM-DD HH:mm");
-	break;
-    case 5:
-	out = t.tz(tz).format("HH:mm");
-	break;
+
+    // Expose public interface
+    ietf_timezone = {
+        get_current_tz: function() {return current_timezone},
+        initialize: timezone_init,
+        set_tz_change_callback: function(cb) {timezone_change_callback=cb},
+        use: use_timezone
     }
-    return out;
-}
-
-
-// Format tooltip notice
-function format_tooltip_notice(start, end) {
-    var notice = "";
-    
-    if (end.isBefore()) {
-	notice = "Event ended " + end.fromNow();
-    } else if (start.isAfter()) {
-	notice = "Event will start " + start.fromNow();
-    } else {
-	notice = "Event started " + start.fromNow() + " and will end " +
-            end.fromNow();
-    }
-    return '<span class="tooltipnotice">' + notice + '</span>';
-}
-
-// Format tooltip table
-function format_tooltip_table(start, end) {
-    var out = '<table><tr><th>Timezone</th><th>Start</th><th>End</th></tr>';
-    if (meeting_timezone != "") {
-	out += '<tr><td class="timehead">Meeting timezone:</td><td>' + 
-	    format_time(start, meeting_timezone, 0) + '</td><td>' +
-	    format_time(end, meeting_timezone, 0) + '</td></tr>';
-    }
-    out += '<tr><td class="timehead">Local timezone:</td><td>' + 
-	format_time(start, local_timezone, 0) + '</td><td>' +
-	format_time(end, local_timezone, 0) + '</td></tr>';
-    if (current_timezone != 'UTC') {
-	out += '<tr><td class="timehead">Selected Timezone:</td><td>' + 
-	    format_time(start, current_timezone, 0) + '</td><td>' +
-	    format_time(end, current_timezone, 0) + '</td></tr>';
-    }
-    out += '<tr><td class="timehead">UTC:</td><td>' + 
-	format_time(start, 'UTC', 0) + '</td><td>' +
-	format_time(end, 'UTC', 0) + '</td></tr>';
-    out += '</table>' + format_tooltip_notice(start, end);
-    return out;
-}
-
-// Format tooltip for item
-function format_tooltip(start, end) {
-    return '<span class="timetooltiptext">' +
-	format_tooltip_table(start, end) +
-	'</span>';
-}
-
-// Add tooltips
-function add_tooltips() {
-    $('span.time').each(function () {
-	var tooltip = $(format_tooltip(this.start_ts, this.end_ts));
-	tooltip[0].start_ts = this.start_ts;
-	tooltip[0].end_ts = this.end_ts;
-	tooltip[0].ustart_ts = moment(this.start_ts).add(-2, 'hours');
-	tooltip[0].uend_ts = moment(this.end_ts).add(2, 'hours');
-	$(this).parent().append(tooltip);
-    });
-}
-
-// Update times on the agenda based on the selected timezone
-function update_times(newtz) {
-    current_timezone = newtz;
-    $('span.current-tz').html(newtz);
-    $('span.time').each(function () {
-	if (this.format == 4) {
-	    var tz = this.start_ts.tz(newtz).format(" z");
-	    if (this.start_ts.tz(newtz).dayOfYear() ==
-		this.end_ts.tz(newtz).dayOfYear()) {
-		$(this).html(format_time(this.start_ts, newtz, this.format) +
-			     '-' + format_time(this.end_ts, newtz, 5) + tz);
-	    } else {
-		$(this).html(format_time(this.start_ts, newtz, this.format) +
-			     '-' +
-			     format_time(this.end_ts, newtz, this.format) + tz);
-	    }
-	} else {
-	    $(this).html(format_time(this.start_ts, newtz, this.format) + '-' +
-			 format_time(this.end_ts, newtz, this.format));
-	}
-    });
-    update_tooltips_all();
-    update_clock();
-    if (timezone_change_callback) {
-        timezone_change_callback(newtz);
-    }
-}
-
-// Highlight ongoing based on the current time
-function highlight_ongoing() {
-    $("div#now").remove("#now");
-    $('.ongoing').removeClass("ongoing");
-    var agenda_rows=$('[data-slot-start-ts]')
-    agenda_rows = agenda_rows.filter(function() {
-        return moment().isBetween(this.slot_start_ts, this.slot_end_ts);
-    });
-    agenda_rows.addClass("ongoing");
-    agenda_rows.first().children("th, td").
-	prepend($('<div id="now" class="anchor-target"></div>'));
-}
-
-// Update tooltips
-function update_tooltips() {
-    var tooltips=$('.timetooltiptext');
-    tooltips.filter(function() {
-	return moment().isBetween(this.ustart_ts, this.uend_ts);
-    }).each(function () {
-	$(this).html(format_tooltip_table(this.start_ts, this.end_ts));
-    });
-}
-
-// Update all tooltips
-function update_tooltips_all() {
-    var tooltips=$('.timetooltiptext');
-    tooltips.each(function () {
-	$(this).html(format_tooltip_table(this.start_ts, this.end_ts));
-    });
-}
-
-// Update clock
-function update_clock() {
-    $('#current-time').html(format_time(moment(), current_timezone, 0));
-}
-
-$.urlParam = function(name) {
-    var results = new RegExp('[\?&]' + name + '=([^&#]*)').exec(window.location.href);
-    if (results == null) {
-	return null;
-    } else {
-	return results[1] || 0;
-    }
-}
-
-function init_timers() {
-    var fast_timer = 60000 / (speedup > 600 ? 600 : speedup);
-    update_clock();
-    highlight_ongoing();
-    setInterval(function() { update_clock(); }, fast_timer);
-    setInterval(function() { highlight_ongoing(); }, fast_timer);
-    setInterval(function() { update_tooltips(); }, fast_timer);
-    setInterval(function() { update_tooltips_all(); }, 3600000 / speedup);
-}
-
-// Register a callback for timezone change
-function set_tz_change_callback(cb) {
-    timezone_change_callback = cb;
-}
\ No newline at end of file
+})();
\ No newline at end of file
diff --git a/ietf/templates/meeting/agenda.html b/ietf/templates/meeting/agenda.html
index 4a71b6988..65217663e 100644
--- a/ietf/templates/meeting/agenda.html
+++ b/ietf/templates/meeting/agenda.html
@@ -40,14 +40,14 @@
     font-weight: normal;
   }
   .tz-display select {
-  min-width: 15em;
+    min-width: 15em;
   }
   #affix .nav li.tz-display {
-  padding: 4px 20px;
+    padding: 4px 20px;
   }
   #affix .nav li.tz-display a {
-  display: inline;
-  padding: 0;
+    display: inline;
+    padding: 0;
   }
 {% endblock %}
 
@@ -76,12 +76,12 @@
             <div class="col-xs-6">
                 <div class="tz-display">
                     <div><small>
-                        <label for="timezone_select">Time zone:</label>
-                        <a id="meeting-timezone" onclick="use_timezone(0)">Meeting</a> |
-                        <a id="local-timezone" onclick="use_timezone(1)">Local</a> |
-                        <a id="utc-timezone" onclick="use_timezone(2)">UTC</a>
+                        <label for="timezone-select">Time zone:</label>
+                        <a id="meeting-timezone" onclick="ietf_timezone.use('{{ timezone }}')">Meeting</a> |
+                        <a id="local-timezone" onclick="ietf_timezone.use('local')">Local</a> |
+                        <a id="utc-timezone" onclick="ietf_timezone.use('UTC')">UTC</a>
                     </small></div>
-                    <select id="timezone_select">
+                    <select id="timezone-select" class="tz-select">
                         {# Avoid blank while loading. JavaScript replaces the option list after init. #}
                         <option selected>{{ timezone }}</option>
                     </select>
@@ -348,23 +348,23 @@
     </div>
     <div class="col-md-2 hidden-print bs-docs-sidebar" id="affix">
       <ul class="nav nav-pills nav-stacked small" data-spy="affix">
-        <li><a href="#now">Now</a></li>
-        {% for item in filtered_assignments %}
-          {% ifchanged item.timeslot.time|date:"Y-m-d" %}
-            <li><a href="#{{item.timeslot.time|slugify}}">{{ item.timeslot.time|date:"l, F j, Y" }}</a></li>
-          {% endifchanged %}
-        {% endfor %}
-        <li><hr/></li>
-        <li class="tz-display">Showing <span class="current-tz">{{ timezone }}</span> time</li>
-        <li class="tz-display"><span> {# span avoids applying nav link styling to these shortcuts #}
-            <a onclick="use_timezone(0)">Meeting time</a> | 
-            <a onclick="use_timezone(1)">Local time</a> |
-            <a onclick="use_timezone(2)">UTC</a></span>
-        </li>
-      {% if settings.DEBUG and settings.DEBUG_AGENDA %}
-        <li><hr/></li>
-        <li><span id="current-time"></span></li>
-        {% endif %}
+          <li><a href="#now">Now</a></li>
+          {% for item in filtered_assignments %}
+              {% ifchanged item.timeslot.time|date:"Y-m-d" %}
+                  <li><a href="#{{item.timeslot.time|slugify}}">{{ item.timeslot.time|date:"l, F j, Y" }}</a></li>
+              {% endifchanged %}
+          {% endfor %}
+          <li><hr/></li>
+          <li class="tz-display">Showing <span class="current-tz">{{ timezone }}</span> time</li>
+          <li class="tz-display"><span> {# span avoids applying nav link styling to these shortcuts #}
+              <a onclick="ietf_timezone.use('{{ timezone }}')">Meeting time</a> |
+              <a onclick="ietf_timezone.use('local')">Local time</a> |
+              <a onclick="ietf_timezone.use('UTC')">UTC</a></span>
+          </li>
+          {% if settings.DEBUG and settings.DEBUG_AGENDA %}
+              <li><hr/></li>
+              <li><span id="current-time"></span></li>
+          {% endif %}
       </ul>
     </div>
   </div>
@@ -376,8 +376,6 @@
 
   <script src="{% static 'ietf/js/agenda/agenda_filter.js' %}"></script>
   <script>
-  var current_timezone = 'UTC';
-
   // Update the agenda display with specified filters
    function update_agenda_display(filter_params) {
        var agenda_rows=$('[id^="row-"]')
@@ -402,7 +400,7 @@
            // this is a "negative" item by wg: when present, hide these rows
            agenda_filter.rows_matching_filter_keyword(agenda_rows, v).hide();
        });
-       
+
        // Now hide any session label rows with no visible sessions. Identify
        // by matching on start/end timestamps.
        $('tr.session-label-row').each(function(i, e) {
@@ -442,15 +440,15 @@
        }
        update_weekview_display();
    }
-   
+
    function update_weekview_display() {
        var weekview = $("#weekview");
        if (!weekview.hasClass('hidden')) {
            var queryparams = window.location.search;
            if (queryparams) {
-               queryparams += '&tz=' + current_timezone.toLowerCase();
+               queryparams += '&tz=' + ietf_timezone.get_current_tz().toLowerCase();
            } else {
-               queryparams = '?tz=' + current_timezone.toLowerCase();
+               queryparams = '?tz=' + ietf_timezone.get_current_tz().toLowerCase();
            }
            var new_url = 'week-view.html' + queryparams;
            var wv_iframe = document.getElementById('weekview');
@@ -545,6 +543,7 @@
   <script src="{% static 'moment/min/moment.min.js' %}"></script>
   <script src="{% static 'moment-timezone/builds/moment-timezone-with-data-10-year-range.min.js' %}"></script>
   <script src="{% static 'ietf/js/agenda/timezone.js' %}"></script>
+  <script src="{% static 'ietf/js/agenda/agenda_timezone.js' %}"></script>
   <script>
 
   {% if settings.DEBUG and settings.DEBUG_AGENDA %}
@@ -567,20 +566,40 @@
   speedup = 1;
   {% endif %}
 
-  // Get meeting and local times, initialize timezone system
-  meeting_timezone = "{{timezone}}";
-  local_timezone = moment.tz.guess();
-  {% if "-utc" in request.path %}
-  timezone_init('UTC');
-  {% else %}
-  timezone_init(meeting_timezone);
-  {% endif %}
 
-  init_timers();
+  $(document).ready(function() {
+      // Methods/variables here that are not in ietf_timezone or agenda_filter are from agenda_timezone.js
+      meeting_timezone = '{{ timezone }}';
 
-  set_tz_change_callback(update_weekview_display);
-  agenda_filter.set_update_callback(update_view);
-  agenda_filter.enable();
+      // First, initialize_moments(). This must be done before calling any of the update methods.
+      // It does not need timezone info, so safe to call before initializing ietf_timezone.
+      initialize_moments();  // fills in moments in the agenda data
 
+      // Now set up callbacks related to ietf_timezone. This must happen before calling initialize().
+      // In particular, set_current_tz_cb() must be called before the update methods are called.
+      set_current_tz_cb(ietf_timezone.get_current_tz);  // give agenda_timezone access to this method
+      ietf_timezone.set_tz_change_callback(function(newtz) {
+          update_times(newtz);
+          update_weekview_display();
+        }
+      );
+
+      // With callbacks in place, call ietf_timezone.initialize(). This will call the tz_change callback
+      // after setting things up.
+      {% if "-utc" in request.path %}
+        ietf_timezone.initialize('UTC');
+      {% else %}
+        ietf_timezone.initialize(meeting_timezone);
+      {% endif %}
+
+      // Now make other setup calls from agenda_timezone.js
+      add_tooltips();
+      init_timers();
+
+      // Finally, set up the agenda filter UI. This does not depend on the timezone.
+      agenda_filter.set_update_callback(update_view);
+      agenda_filter.enable();
+    }
+  );
   </script>
 {% endblock %}
diff --git a/ietf/templates/meeting/upcoming.html b/ietf/templates/meeting/upcoming.html
index 4adfda813..d3d30fc17 100644
--- a/ietf/templates/meeting/upcoming.html
+++ b/ietf/templates/meeting/upcoming.html
@@ -15,20 +15,56 @@
 
 {% block title %}Upcoming Meetings{% endblock %}
 
+{% block morecss %}
+    div.title-buttons {
+        margin-bottom: 0.5em;
+        margin-top: 1em;
+        text-align: right;
+    }
+    .tz-display {
+        margin-top: 0.5em;
+    }
+    .tz-display label {
+        font-weight: normal;
+    }
+    .tz-display a {
+        cursor: pointer;
+    }
+    select.tz-select {
+        min-width: 15em;
+        margin-bottom: 0.3em;
+    }
+{% endblock %}
 {% block content %}
   {% origin %}
   <div class="row">
     <div class="col-md-10">
+        <div class="row">
+            <div class="col-xs-6">
+                <h1>Upcoming Meetings</h1>
+            </div>
+            <div class="title-buttons col-xs-6">
+                <div>
+                    <a title="iCalendar subscription for upcoming meetings" href="webcal://{{request.get_host}}{% url 'ietf.meeting.views.upcoming_ical' %}">
+                        <span class="fa fa-stack-1"><i class="fa fa-fw fa-calendar-o fa-stack-1x"></i><i class="fa fa-fw fa-repeat fa-stack-xs"></i></span>
+                    </a>
+                    <a title="iCalendar entry for upcoming meetings" href="{% url 'ietf.meeting.views.upcoming_ical' %}"><span class="fa fa-calendar"></span></a>
+                </div>
+                <div class="tz-display">
+                    <label for="timezone-select">Time zone:</label>
+                    <small>
+                        <a id="local-timezone" onclick="ietf_timezone.use('local')">Local</a> |
+                        <a id="utc-timezone" onclick="ietf_timezone.use('UTC')">UTC</a>
+                    </small>
+                    <select class="tz-select" id="timezone-select" autocomplete="off">
+                        <!-- Avoid blank while loading. Needs to agree with native times in the table
+                             so the display is correct if JS is not enabled -->
+                        <option selected>UTC</option>
+                    </select>
 
-      <h1>Upcoming Meetings
-         <span class="regular pull-right">
-         <a title="iCalendar subscription for upcoming meetings" href="webcal://{{request.get_host}}{% url 'ietf.meeting.views.upcoming_ical' %}">
-            <span class="fa fa-stack-1"><i class="fa fa-fw fa-calendar-o fa-stack-1x"></i><i class="fa fa-fw fa-repeat fa-stack-xs"></i></span>
-         </a>
-         <a title="iCalendar entry for upcoming meetings" href="{% url 'ietf.meeting.views.upcoming_ical' %}"><span class="fa fa-calendar"></span></a>
-         </span>
-      </h1>
-
+                </div>
+            </div>
+        </div>
       <p>For more on regular IETF meetings see <a href="https://www.ietf.org/meeting/upcoming.html">here</a></p>
       <p>Meeting important dates are not included in upcoming meeting calendars. They have <a href="{% url 'ietf.meeting.views.important_dates' %}">their own calendar</a></p>
 
@@ -67,18 +103,27 @@
             </thead>
             <tbody>
               {% for entry in entries %}
-                <tr class="entry" 
+                <tr class="entry"
                     {% if entry|classname == 'Session' %}data-filter-keywords="{{ entry.filter_keywords|join:',' }}"{% endif %}>
                   {% if entry|classname == 'Meeting' %}
                     {% with meeting=entry %}
-                      <td>{{ meeting.date }} - {{ meeting.end }}</td>
+                      <td class="meeting-time"
+                          data-start-date="{{ meeting.date }}" {# dates local to meeting #}
+                          data-end-date="{{ meeting.end }}"
+                          data-time-zone="{{ meeting.time_zone }}">
+                          {{ meeting.date }} - {{ meeting.end }}
+                      </td>
                       <td>ietf</td>
                       <td><a class="ietf-meeting-link" href="{% url 'ietf.meeting.views.agenda' num=meeting.number %}">IETF {{ meeting.number }}</a></td>
                       <td></td>
                     {% endwith %}
                   {% elif entry|classname == 'Session' %}
                     {% with session=entry group=entry.group meeting=entry.meeting%}
-                      <td>{{ session.official_timeslotassignment.timeslot.utc_start_time | date:"Y-m-d H:i"}} - {{ session.official_timeslotassignment.timeslot.utc_end_time | date:"H:i e" }}</td>
+                      <td class="session-time"
+                          data-start-utc="{{ session.official_timeslotassignment.timeslot.utc_start_time | date:'Y-m-d H:i' }}Z"
+                          data-end-utc="{{ session.official_timeslotassignment.timeslot.utc_end_time | date:'Y-m-d H:i' }}Z">
+                          {{ session.official_timeslotassignment.timeslot.utc_start_time | date:"Y-m-d H:i"}} - {{ session.official_timeslotassignment.timeslot.utc_end_time | date:"H:i" }}
+                      </td>
                       <td><a href="{% url 'ietf.group.views.group_home' acronym=group.acronym %}">{{ group.acronym }}</a></td>
                       <td>
                         <a class="interim-meeting-link" href="{% url 'ietf.meeting.views.session_details' num=meeting.number acronym=group.acronym %}"> {{ meeting.number }}</a>
@@ -109,34 +154,52 @@
       {% endcache %}
     </div>
   </div>
-  <div id="calendar" class="col-md-10" ></div>
+    <div class="row">
+        <div class="col-md-10">
+            <div class="tz-display text-right">
+                <label for="timezone-select-bottom">Time zone: </label>
+                <small>
+                    <a id="local-timezone-bottom" onclick="ietf_timezone.use('local')">Local</a> |
+                    <a id="utc-timezone-bottom" onclick="ietf_timezone.use('UTC')">UTC</a>
+                </small>
+                <select class="tz-select" id="timezone-select-bottom"></select>
+            </div>
+        </div>
+    </div>
+    <div class="row">
+        <div class="col-md-10">
+            <div id="calendar"></div>
+        </div>
+    </div>
 {% endblock %}
 
 {% block js %}
 <script src="{% static "jquery.tablesorter/js/jquery.tablesorter.combined.min.js" %}"></script>
 <script src="{% static 'fullcalendar/core/main.js' %}"></script>
 <script src="{% static 'fullcalendar/daygrid/main.js' %}"></script>
+    <script src="{% static 'moment/min/moment.min.js' %}"></script>
+    <script src="{% static 'moment-timezone/builds/moment-timezone-with-data-10-year-range.min.js' %}"></script>
     <script src="{% static 'ietf/js/agenda/agenda_filter.js' %}"></script>
+    <script src="{% static 'ietf/js/agenda/timezone.js' %}"></script>
     <script>
       // List of all events with meta-info needed for filtering
       var all_event_list = [{% for entry in entries %}
           {% if entry|classname == 'Meeting' %}
               {% with meeting=entry %}
                   {
-                      title: 'IETF {{ meeting.number }}',
-                      start: '{{meeting.date}}',
-                      end: '{{meeting.end}}',
+                      ietf_meeting_number: '{{ meeting.number }}',
+                      start_moment: moment.tz('{{meeting.date}}', '{{ meeting.time_zone }}').startOf('day'),
+                      end_moment: moment.tz('{{meeting.end}}', '{{ meeting.time_zone }}').endOf('day'),
                       url: '{% url 'ietf.meeting.views.agenda' num=meeting.number %}'
                   }{% if not forloop.last %}, {% endif %}
               {% endwith %}
           {% else %} {# if it's not a Meeting, it's a Session #}
               {% with session=entry %}
                   {
-                      title: '{{session.official_timeslotassignment.timeslot.utc_start_time|date:"H:i"}}-{{session.official_timeslotassignment.timeslot.utc_end_time|date:"H:i"}}',
                       group: '{% if session.group %}{{session.group.acronym}}{% endif %}',
                       filter_keywords: ["{{ session.filter_keywords|join:'","' }}"],
-                      start: '{{session.official_timeslotassignment.timeslot.utc_start_time | date:"Y-m-d H:i"}}',
-                      end: '{{session.official_timeslotassignment.timeslot.utc_end_time | date:"Y-m-d H:i"}}',
+                      start_moment: moment.utc('{{session.official_timeslotassignment.timeslot.utc_start_time | date:"Y-m-d H:i"}}'),
+                      end_moment: moment.utc('{{session.official_timeslotassignment.timeslot.utc_end_time | date:"Y-m-d H:i"}}'),
                       url: '{% url 'ietf.meeting.views.session_details' num=session.meeting.number acronym=session.group.acronym %}'
                   }
               {% endwith %}
@@ -144,7 +207,9 @@
           {% endif %}
       {% endfor %}];
       var filtered_event_list = []; // currently visible list
+      var display_events = []; // filtered events, processed for calendar display
       var event_calendar; // handle on the calendar object
+      var current_tz = 'UTC';
 
       // Test whether an event should be visible given a set of filter parameters
       function calendar_event_visible(filter_params, event) {
@@ -158,28 +223,55 @@
                   && agenda_filter.keyword_match(filter_params.show, event.filter_keywords));
       }
 
-      // Apply filter_params to the event list and format data for the calendar
+      /* Apply filter_params to the event list */
       function filter_calendar_events(filter_params, event_list) {
-          var calendarEl = document.getElementById('calendar');
-          var glue = calendarEl.clientWidth > 720 ? ' ' : '\n';
           var filtered_output = [];
           for (var ii = 0; ii < event_list.length; ii++) {
               var this_event = event_list[ii];
               if (calendar_event_visible(filter_params, this_event)) {
-                  filtered_output.push({
-                      title: this_event.title + (this_event.group ? (glue + this_event.group) : ''),
-                      start: this_event.start,
-                      end: this_event.end,
-                      url: this_event.url
-                  })
+                  filtered_output.push(this_event);
               }
           }
           return filtered_output;
       }
 
-      // Initialize or update the calendar, updating the filtered event list
-      function update_calendar(filter_params) {
-          filtered_event_list = filter_calendar_events(filter_params, all_event_list);
+      // format a moment in a tz
+      var moment_formats = {time: 'HH:mm', date: 'YYYY-MM-DD', datetime: 'YYYY-MM-DD HH:mm'};
+      function format_moment(t_moment, tz, fmt_type) {
+        return t_moment.tz(tz).format(moment_formats[fmt_type]);
+      }
+
+      function make_display_events(event_data, tz) {
+          var output = [];
+          var calendarEl = document.getElementById('calendar');
+          var glue = calendarEl.clientWidth > 720 ? ' ' : '\n';
+          return $.map(event_data, function(src_event) {
+              var title;
+              // Render IETF meetings with meeting dates, sessions with actual times
+              if (src_event.ietf_meeting_number) {
+                  title = 'IETF ' + src_event.ietf_meeting_number;
+              } else {
+                  title = (format_moment(src_event.start_moment, tz, 'time') + '-'
+                    + format_moment(src_event.end_moment, tz, 'time')
+                    + glue + (src_event.group || 'Invalid event'));
+              }
+              return {
+                  title: title,
+                  start: format_moment(src_event.start_moment, tz, 'datetime'),
+                  end: format_moment(src_event.end_moment, tz, 'datetime'),
+                  url: src_event.url
+              }; // all events have the URL
+          });
+      }
+
+      // Initialize or update the calendar, updating the filtered event list and/or timezone
+      function update_calendar(tz, filter_params) {
+          if (filter_params) {
+              // Update event list if we were called with filter params
+              filtered_event_list = filter_calendar_events(filter_params, all_event_list);
+          }
+          display_events = make_display_events(filtered_event_list, tz);
+
           if (event_calendar) {
               event_calendar.refetchEvents()
           } else {
@@ -191,7 +283,7 @@
               event_calendar = new FullCalendar.Calendar(calendarEl, {
                   plugins: ['dayGrid'],
                   displayEventTime: false,
-                  events: function (fInfo, success) {success(filtered_event_list)},
+                  events: function (fInfo, success) {success(display_events)},
                   eventRender: function (info) {
                       $(info.el).tooltip({ title: info.event.title })
                   },
@@ -239,7 +331,7 @@
       function update_view(filter_params) {
           update_meeting_display(filter_params);
           update_links(filter_params);
-          update_calendar(filter_params);
+          update_calendar(current_tz, filter_params);
       }
 
       // Set up the filtering - the callback will be called when the page loads and on any filter changes
@@ -276,5 +368,38 @@
 	      });
 	  }
       });
+
+      function format_session_time(session_elt, tz) {
+          var start = moment.utc($(session_elt).attr('data-start-utc'));
+          var end = moment.utc($(session_elt).attr('data-end-utc'));
+          return format_moment(start, tz, 'datetime') + ' - ' + format_moment(end, tz, 'time');
+      }
+
+      function format_meeting_time(meeting_elt, tz) {
+          var meeting_tz = $(meeting_elt).attr('data-time-zone');
+          var start = moment.tz($(meeting_elt).attr('data-start-date'), meeting_tz).startOf('day');
+          var end = moment.tz($(meeting_elt).attr('data-end-date'), meeting_tz).endOf('day');
+          return format_moment(start, tz, 'date') + ' - ' + format_moment(end, tz, 'date');
+      }
+
+      function timezone_changed(newtz) {
+          // update times for events in the table
+          if (current_tz !== newtz) {
+              current_tz = newtz;
+              $('.session-time').each(function () {
+                  $(this).html(format_session_time(this, newtz));
+              });
+              $('.meeting-time').each(function () {
+                  $(this).html(format_meeting_time(this, newtz));
+              });
+          }
+
+          update_calendar(newtz);
+      }
+
+
+    // Init with best guess at local timezone.
+    ietf_timezone.set_tz_change_callback(timezone_changed);
+    ietf_timezone.initialize('local');
     </script>
 {% endblock %}