From 1054f90873fae2e706831326b5a36b756b1ef460 Mon Sep 17 00:00:00 2001
From: Jennifer Richards <jennifer@painless-security.com>
Date: Tue, 12 Oct 2021 17:08:58 +0000
Subject: [PATCH] Snapshot of dev work to add session purpose annotation  -
 Legacy-Id: 19415

---
 ietf/doc/templatetags/ietf_filters.py         |   87 ++
 ietf/group/admin.py                           |    1 +
 .../0049_groupfeatures_agenda_filter_type.py  |   20 +
 ...pulate_groupfeatures_agenda_filter_type.py |   35 +
 .../0051_groupfeatures_session_purposes.py    |   24 +
 ...populate_groupfeatures_session_purposes.py |   48 +
 ietf/group/models.py                          |   13 +-
 ietf/meeting/fields.py                        |  130 ++
 ietf/meeting/forms.py                         |  217 +++-
 ietf/meeting/helpers.py                       |  484 +++++++-
 .../migrations/0049_session_purpose.py        |   21 +
 ietf/meeting/models.py                        |   46 +-
 .../templatetags/agenda_custom_tags.py        |   86 +-
 ietf/meeting/templatetags/tests.py            |   20 +
 ietf/meeting/tests_helpers.py                 |  272 ++++-
 ietf/meeting/tests_js.py                      |  211 +++-
 ietf/meeting/tests_views.py                   | 1070 ++++++++++++++++-
 ietf/meeting/urls.py                          |    2 +
 ietf/meeting/utils.py                         |    8 +
 ietf/meeting/views.py                         |  326 +++--
 ietf/name/admin.py                            |    5 +-
 ietf/name/fixtures/names.json                 |   73 ++
 .../migrations/0032_agendafiltertypename.py   |   27 +
 .../0033_populate_agendafiltertypename.py     |   33 +
 .../0034_add_officehours_timeslottypename.py  |   29 +
 .../migrations/0035_sessionpurposename.py     |   32 +
 .../0036_populate_sessionpurposename.py       |   48 +
 ietf/name/models.py                           |   12 +
 ietf/name/resources.py                        |   19 +-
 ietf/secr/meetings/forms.py                   |   26 +-
 ietf/secr/meetings/views.py                   |   39 +-
 ietf/secr/sreq/forms.py                       |   71 +-
 ietf/secr/sreq/templatetags/ams_filters.py    |   20 +-
 ietf/secr/sreq/views.py                       |  196 ++-
 .../js/session_purpose_and_type_widget.js     |   83 ++
 ietf/secr/static/secr/js/sessions.js          |  116 +-
 .../includes/sessions_request_form.html       |   15 +-
 .../includes/sessions_request_view.html       |   24 +-
 .../templates/meetings/misc_session_edit.html |    8 +
 .../templates/meetings/misc_sessions.html     |   16 +-
 ietf/secr/templates/meetings/rooms.html       |    8 +-
 ietf/secr/templates/meetings/times.html       |    2 +-
 ietf/secr/templates/sreq/confirm.html         |   14 +-
 ietf/settings.py                              |    3 +
 ietf/static/ietf/css/ietf.css                 |   37 +
 .../ietf/js/meeting/session_details_form.js   |  109 ++
 ietf/templates/meeting/agenda.html            |  209 ++--
 ietf/templates/meeting/agenda.txt             |   10 +-
 ietf/templates/meeting/agenda_by_room.html    |    2 +-
 ietf/templates/meeting/agenda_filter.html     |    6 +-
 ietf/templates/meeting/create_timeslot.html   |   27 +
 ietf/templates/meeting/edit_timeslot.html     |   35 +
 .../meeting/interim_session_buttons.html      |    4 +-
 ietf/templates/meeting/room-view.html         |    2 +-
 ietf/templates/meeting/schedule_list.html     |    3 +
 .../meeting/session_buttons_include.html      |    5 +-
 .../meeting/session_details_form.html         |   24 +
 ietf/templates/meeting/timeslot_edit.html     |  433 ++++++-
 .../meeting/timeslot_edit_timeslot.html       |   26 +
 ietf/utils/fields.py                          |   67 +-
 ietf/utils/validators.py                      |   38 +-
 61 files changed, 4342 insertions(+), 735 deletions(-)
 create mode 100644 ietf/group/migrations/0049_groupfeatures_agenda_filter_type.py
 create mode 100644 ietf/group/migrations/0050_populate_groupfeatures_agenda_filter_type.py
 create mode 100644 ietf/group/migrations/0051_groupfeatures_session_purposes.py
 create mode 100644 ietf/group/migrations/0052_populate_groupfeatures_session_purposes.py
 create mode 100644 ietf/meeting/fields.py
 create mode 100644 ietf/meeting/migrations/0049_session_purpose.py
 create mode 100644 ietf/meeting/templatetags/tests.py
 create mode 100644 ietf/name/migrations/0032_agendafiltertypename.py
 create mode 100644 ietf/name/migrations/0033_populate_agendafiltertypename.py
 create mode 100644 ietf/name/migrations/0034_add_officehours_timeslottypename.py
 create mode 100644 ietf/name/migrations/0035_sessionpurposename.py
 create mode 100644 ietf/name/migrations/0036_populate_sessionpurposename.py
 create mode 100644 ietf/secr/static/secr/js/session_purpose_and_type_widget.js
 create mode 100644 ietf/static/ietf/js/meeting/session_details_form.js
 create mode 100755 ietf/templates/meeting/create_timeslot.html
 create mode 100644 ietf/templates/meeting/edit_timeslot.html
 create mode 100644 ietf/templates/meeting/session_details_form.html
 create mode 100644 ietf/templates/meeting/timeslot_edit_timeslot.html

diff --git a/ietf/doc/templatetags/ietf_filters.py b/ietf/doc/templatetags/ietf_filters.py
index d59a13ef6..fe7de0a6f 100644
--- a/ietf/doc/templatetags/ietf_filters.py
+++ b/ietf/doc/templatetags/ietf_filters.py
@@ -613,6 +613,93 @@ def action_holder_badge(action_holder):
     else:
         return ''  # no alert needed
 
+@register.filter
+def is_regular_agenda_item(assignment):
+    """Is this agenda item a regular session item?
+
+    A regular item appears as a sub-entry in a timeslot within the agenda
+
+    >>> from collections import namedtuple  # use to build mock objects
+    >>> mock_timeslot = namedtuple('t2', ['slug'])
+    >>> mock_assignment = namedtuple('t1', ['slot_type'])  # slot_type must be a callable
+    >>> factory = lambda t: mock_assignment(slot_type=lambda: mock_timeslot(slug=t))
+    >>> is_regular_agenda_item(factory('regular'))
+    True
+
+    >>> any(is_regular_agenda_item(factory(t)) for t in ['plenary', 'break', 'reg', 'other', 'officehours'])
+    False
+    """
+    return assignment.slot_type().slug == 'regular'
+
+@register.filter
+def is_plenary_agenda_item(assignment):
+    """Is this agenda item a regular session item?
+
+    A regular item appears as a sub-entry in a timeslot within the agenda
+
+    >>> from collections import namedtuple  # use to build mock objects
+    >>> mock_timeslot = namedtuple('t2', ['slug'])
+    >>> mock_assignment = namedtuple('t1', ['slot_type'])  # slot_type must be a callable
+    >>> factory = lambda t: mock_assignment(slot_type=lambda: mock_timeslot(slug=t))
+    >>> is_plenary_agenda_item(factory('plenary'))
+    True
+
+    >>> any(is_plenary_agenda_item(factory(t)) for t in ['regular', 'break', 'reg', 'other', 'officehours'])
+    False
+    """
+    return assignment.slot_type().slug == 'plenary'
+
+@register.filter
+def is_special_agenda_item(assignment):
+    """Is this agenda item a special item?
+
+    Special items appear as top-level agenda entries with their own timeslot information.
+
+    >>> from collections import namedtuple  # use to build mock objects
+    >>> mock_timeslot = namedtuple('t2', ['slug'])
+    >>> mock_assignment = namedtuple('t1', ['slot_type'])  # slot_type must be a callable
+    >>> factory = lambda t: mock_assignment(slot_type=lambda: mock_timeslot(slug=t))
+    >>> all(is_special_agenda_item(factory(t)) for t in ['break', 'reg', 'other', 'officehours'])
+    True
+
+    >>> any(is_special_agenda_item(factory(t)) for t in ['regular', 'plenary'])
+    False
+    """
+    return assignment.slot_type().slug in [
+        'break',
+        'reg',
+        'other',
+        'officehours',
+    ]
+
+@register.filter
+def should_show_agenda_session_buttons(assignment):
+    """Should this agenda item show the session buttons (jabber link, etc)?
+
+    In IETF-111 and earlier, office hours sessions were designated by a name ending
+    with ' office hours' and belonged to the IESG or some other group. This led to
+    incorrect session buttons being displayed. Suppress session buttons for
+    when name ends with 'office hours' in the pre-111 meetings.
+    >>> from collections import namedtuple  # use to build mock objects
+    >>> mock_meeting = namedtuple('t3', ['number'])
+    >>> mock_session = namedtuple('t2', ['name'])
+    >>> mock_assignment = namedtuple('t1', ['meeting', 'session'])  # meeting must be a callable
+    >>> factory = lambda num, name: mock_assignment(session=mock_session(name), meeting=lambda: mock_meeting(num))
+    >>> test_cases = [('105', 'acme office hours'), ('111', 'acme office hours')]
+    >>> any(should_show_agenda_session_buttons(factory(*tc)) for tc in test_cases)
+    False
+    >>> test_cases = [('interim-2020-acme-112', 'acme'), ('112', 'acme'), ('150', 'acme'), ('105', 'acme'),]
+    >>> test_cases.extend([('111', 'acme'), ('interim-2020-acme-112', 'acme office hours')])
+    >>> test_cases.extend([('112', 'acme office hours'), ('150', 'acme office hours')])
+    >>> all(should_show_agenda_session_buttons(factory(*tc)) for tc in test_cases)
+    True
+    """
+    num = assignment.meeting().number
+    if num.isdigit() and int(num) <= settings.MEETING_LEGACY_OFFICE_HOURS_END:
+        return not assignment.session.name.lower().endswith(' office hours')
+    else:
+        return True
+
 
 @register.simple_tag
 def absurl(viewname, **kwargs):
diff --git a/ietf/group/admin.py b/ietf/group/admin.py
index bae546648..8bbee7a13 100644
--- a/ietf/group/admin.py
+++ b/ietf/group/admin.py
@@ -188,6 +188,7 @@ class GroupFeaturesAdmin(admin.ModelAdmin):
         'customize_workflow',
         'is_schedulable',
         'show_on_agenda',
+        'agenda_filter_type',
         'req_subm_approval',
         'agenda_type',
         'material_types',
diff --git a/ietf/group/migrations/0049_groupfeatures_agenda_filter_type.py b/ietf/group/migrations/0049_groupfeatures_agenda_filter_type.py
new file mode 100644
index 000000000..e7272d571
--- /dev/null
+++ b/ietf/group/migrations/0049_groupfeatures_agenda_filter_type.py
@@ -0,0 +1,20 @@
+# Copyright The IETF Trust 2021 All Rights Reserved
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('name', '0033_populate_agendafiltertypename'),
+        ('group', '0048_has_session_materials'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='groupfeatures',
+            name='agenda_filter_type',
+            field=models.ForeignKey(default='none', on_delete=django.db.models.deletion.PROTECT, to='name.AgendaFilterTypeName'),
+        ),
+    ]
diff --git a/ietf/group/migrations/0050_populate_groupfeatures_agenda_filter_type.py b/ietf/group/migrations/0050_populate_groupfeatures_agenda_filter_type.py
new file mode 100644
index 000000000..5bed0235f
--- /dev/null
+++ b/ietf/group/migrations/0050_populate_groupfeatures_agenda_filter_type.py
@@ -0,0 +1,35 @@
+# Copyright The IETF Trust 2021 All Rights Reserved
+
+from django.db import migrations
+
+
+def forward(apps, schema_editor):
+    GroupFeatures = apps.get_model('group', 'GroupFeatures')
+
+    # map AgendaFilterTypeName slug to group types - unlisted get 'none'
+    filter_types = dict(
+        # list previously hard coded in agenda view, plus 'review'
+        normal={'wg', 'ag', 'rg', 'rag', 'iab', 'program', 'review'},
+        heading={'area', 'ietf', 'irtf'},
+        special={'team', 'adhoc'},
+    )
+
+    for ft, group_types in filter_types.items():
+        for gf in GroupFeatures.objects.filter(type__slug__in=group_types):
+            gf.agenda_filter_type_id = ft
+            gf.save()
+
+
+def reverse(apps, schema_editor):
+    pass  # nothing to do, model will be deleted anyway
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('group', '0049_groupfeatures_agenda_filter_type'),
+    ]
+
+    operations = [
+        migrations.RunPython(forward, reverse),
+    ]
diff --git a/ietf/group/migrations/0051_groupfeatures_session_purposes.py b/ietf/group/migrations/0051_groupfeatures_session_purposes.py
new file mode 100644
index 000000000..d6adfca56
--- /dev/null
+++ b/ietf/group/migrations/0051_groupfeatures_session_purposes.py
@@ -0,0 +1,24 @@
+# Copyright The IETF Trust 2021 All Rights Reserved
+
+# Generated by Django 2.2.24 on 2021-09-26 11:29
+
+from django.db import migrations
+import ietf.group.models
+import ietf.name.models
+import jsonfield.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('group', '0050_populate_groupfeatures_agenda_filter_type'),
+        ('name', '0035_sessionpurposename'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='groupfeatures',
+            name='session_purposes',
+            field=jsonfield.fields.JSONField(default=[], help_text='Allowed session purposes for this group type', max_length=256, validators=[ietf.group.models.JSONForeignKeyListValidator(ietf.name.models.SessionPurposeName)]),
+        ),
+    ]
diff --git a/ietf/group/migrations/0052_populate_groupfeatures_session_purposes.py b/ietf/group/migrations/0052_populate_groupfeatures_session_purposes.py
new file mode 100644
index 000000000..5ad6bdbd9
--- /dev/null
+++ b/ietf/group/migrations/0052_populate_groupfeatures_session_purposes.py
@@ -0,0 +1,48 @@
+# Copyright The IETF Trust 2021 All Rights Reserved
+
+# Generated by Django 2.2.24 on 2021-09-26 11:29
+
+from django.db import migrations
+
+
+default_purposes = dict(
+    dir=['presentation', 'social', 'tutorial'],
+    ietf=['admin', 'presentation', 'social'],
+    rg=['session'],
+    team=['coding', 'presentation', 'social', 'tutorial'],
+    wg=['session'],
+)
+
+
+def forward(apps, schema_editor):
+    GroupFeatures = apps.get_model('group', 'GroupFeatures')
+    SessionPurposeName = apps.get_model('name', 'SessionPurposeName')
+
+    # verify that we're not about to use an invalid purpose
+    for purposes in default_purposes.values():
+        for purpose in purposes:
+            SessionPurposeName.objects.get(pk=purpose)  # throws an exception unless exists
+
+    for type_, purposes in default_purposes.items():
+        GroupFeatures.objects.filter(
+            type=type_
+        ).update(
+            session_purposes=purposes
+        )
+
+def reverse(apps, schema_editor):
+    GroupFeatures = apps.get_model('group', 'GroupFeatures')
+    GroupFeatures.objects.update(session_purposes=[])  # clear back out to default
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('group', '0051_groupfeatures_session_purposes'),
+        ('name', '0036_populate_sessionpurposename'),
+
+    ]
+
+    operations = [
+        migrations.RunPython(forward, reverse),
+    ]
diff --git a/ietf/group/models.py b/ietf/group/models.py
index aae5c4807..8e2c55f9c 100644
--- a/ietf/group/models.py
+++ b/ietf/group/models.py
@@ -13,7 +13,7 @@ from urllib.parse import urljoin
 from django.conf import settings
 from django.core.validators import RegexValidator
 from django.db import models
-from django.db.models.deletion import CASCADE
+from django.db.models.deletion import CASCADE, PROTECT
 from django.dispatch import receiver
 
 #from simple_history.models import HistoricalRecords
@@ -21,11 +21,13 @@ from django.dispatch import receiver
 import debug                            # pyflakes:ignore
 
 from ietf.group.colors import fg_group_colors, bg_group_colors
-from ietf.name.models import GroupStateName, GroupTypeName, DocTagName, GroupMilestoneStateName, RoleName, AgendaTypeName, ExtResourceName
+from ietf.name.models import (GroupStateName, GroupTypeName, DocTagName, GroupMilestoneStateName, RoleName, 
+                              AgendaTypeName, AgendaFilterTypeName, ExtResourceName, SessionPurposeName)
 from ietf.person.models import Email, Person
 from ietf.utils.mail import formataddr, send_mail_text
 from ietf.utils import log
 from ietf.utils.models import ForeignKey, OneToOneField
+from ietf.utils.validators import JSONForeignKeyListValidator
 
 
 class GroupInfo(models.Model):
@@ -250,6 +252,7 @@ validate_comma_separated_roles = RegexValidator(
     code='invalid',
 )
 
+
 class GroupFeatures(models.Model):
     type = OneToOneField(GroupTypeName, primary_key=True, null=False, related_name='features')
     #history = HistoricalRecords()
@@ -277,6 +280,7 @@ class GroupFeatures(models.Model):
     customize_workflow      = models.BooleanField("Workflow",   default=False)
     is_schedulable          = models.BooleanField("Schedulable",default=False)
     show_on_agenda          = models.BooleanField("On Agenda",  default=False)
+    agenda_filter_type      = models.ForeignKey(AgendaFilterTypeName, default='none', on_delete=PROTECT)
     req_subm_approval       = models.BooleanField("Subm. Approval",  default=False)
     #
     agenda_type             = models.ForeignKey(AgendaTypeName, null=True, default="ietf", on_delete=CASCADE)
@@ -290,7 +294,10 @@ class GroupFeatures(models.Model):
     groupman_authroles      = jsonfield.JSONField(max_length=128, blank=False, default=["Secretariat",])
     matman_roles            = jsonfield.JSONField(max_length=128, blank=False, default=["ad","chair","delegate","secr"])
     role_order              = jsonfield.JSONField(max_length=128, blank=False, default=["chair","secr","member"],
-                                                help_text="The order in which roles are shown, for instance on photo pages.  Enter valid JSON.")
+                                                  help_text="The order in which roles are shown, for instance on photo pages.  Enter valid JSON.")
+    session_purposes        = jsonfield.JSONField(max_length=256, blank=False, default=[],
+                                                  help_text="Allowed session purposes for this group type",
+                                                  validators=[JSONForeignKeyListValidator(SessionPurposeName)])
 
 
 class GroupHistory(GroupInfo):
diff --git a/ietf/meeting/fields.py b/ietf/meeting/fields.py
new file mode 100644
index 000000000..06533f41c
--- /dev/null
+++ b/ietf/meeting/fields.py
@@ -0,0 +1,130 @@
+import json
+from collections import namedtuple
+
+from django import forms
+
+from ietf.name.models import SessionPurposeName, TimeSlotTypeName
+
+import debug
+
+class SessionPurposeAndTypeWidget(forms.MultiWidget):
+    css_class = 'session_purpose_widget'  # class to apply to all widgets
+
+    def __init__(self, purpose_choices, type_choices, *args, **kwargs):
+        # Avoid queries on models that need to be migrated into existence - this widget is
+        # instantiated during Django setup. Attempts to query, e.g., SessionPurposeName will
+        # prevent migrations from running.
+        widgets = (
+            forms.Select(
+                choices=purpose_choices,
+                attrs={
+                    'class': self.css_class,
+                },
+            ),
+            forms.Select(
+                choices=type_choices,
+                attrs={
+                    'class': self.css_class,
+                    'data-allowed-options': None,
+                },
+            ),
+        )
+        super().__init__(widgets=widgets, *args, **kwargs)
+
+    # These queryset properties are needed to propagate changes to the querysets after initialization
+    # down to the widgets. The usual mechanisms in the ModelChoiceFields don't handle this for us
+    # because the subwidgets are not attached to Fields in the usual way.
+    @property
+    def purpose_choices(self):
+        return self.widgets[0].choices
+
+    @purpose_choices.setter
+    def purpose_choices(self, value):
+        self.widgets[0].choices = value
+
+    @property
+    def type_choices(self):
+        return self.widgets[1].choices
+
+    @type_choices.setter
+    def type_choices(self, value):
+        self.widgets[1].choices = value
+
+    def render(self, *args, **kwargs):
+        # Fill in the data-allowed-options (could not do this in init because it needs to
+        # query SessionPurposeName, which will break the migration if done during initialization)
+        self.widgets[1].attrs['data-allowed-options'] = json.dumps(self._allowed_types())
+        return super().render(*args, **kwargs)
+
+    def decompress(self, value):
+        if value:
+            return [getattr(val, 'pk', val) for val in value]
+        else:
+            return [None, None]
+
+    class Media:
+        js = ('secr/js/session_purpose_and_type_widget.js',)
+
+    def _allowed_types(self):
+        """Map from purpose to allowed type values"""
+        return {
+            purpose.slug: list(purpose.timeslot_types)
+            for purpose in SessionPurposeName.objects.all()
+        }
+
+
+class SessionPurposeAndTypeField(forms.MultiValueField):
+    """Field to update Session purpose and type
+
+    Uses SessionPurposeAndTypeWidget to coordinate setting the session purpose and type to valid
+    combinations. Its value should be a tuple with (purpose, type). Its cleaned value is a
+    namedtuple with purpose and value properties.
+    """
+    def __init__(self, purpose_queryset=None, type_queryset=None, **kwargs):
+        if purpose_queryset is None:
+            purpose_queryset = SessionPurposeName.objects.none()
+        if type_queryset is None:
+            type_queryset = TimeSlotTypeName.objects.none()
+        fields = (
+            forms.ModelChoiceField(queryset=purpose_queryset, label='Purpose'),
+            forms.ModelChoiceField(queryset=type_queryset, label='Type'),
+        )
+        self.widget = SessionPurposeAndTypeWidget(*(field.choices for field in fields))
+        super().__init__(fields=fields, **kwargs)
+
+    @property
+    def purpose_queryset(self):
+        return self.fields[0].queryset
+
+    @purpose_queryset.setter
+    def purpose_queryset(self, value):
+        self.fields[0].queryset = value
+        self.widget.purpose_choices = self.fields[0].choices
+
+    @property
+    def type_queryset(self):
+        return self.fields[1].queryset
+
+    @type_queryset.setter
+    def type_queryset(self, value):
+        self.fields[1].queryset = value
+        self.widget.type_choices = self.fields[1].choices
+
+    def compress(self, data_list):
+        # Convert data from the cleaned list from the widget into a namedtuple
+        if data_list:
+            compressed = namedtuple('CompressedSessionPurposeAndType', 'purpose type')
+            return compressed(*data_list)
+        return None
+
+    def validate(self, value):
+        # Additional validation - value has been passed through compress() already
+        if value.type.pk not in value.purpose.timeslot_types:
+            raise forms.ValidationError(
+                '"%(type)s" is not an allowed type for the purpose "%(purpose)s"',
+                params={'type': value.type, 'purpose': value.purpose},
+                code='invalid_type',
+            )
+
+
+
diff --git a/ietf/meeting/forms.py b/ietf/meeting/forms.py
index c082baebf..e63256e67 100644
--- a/ietf/meeting/forms.py
+++ b/ietf/meeting/forms.py
@@ -5,9 +5,11 @@
 import io
 import os
 import datetime
+import json
 
 from django import forms
 from django.conf import settings
+from django.core import validators
 from django.core.exceptions import ValidationError
 from django.db.models import Q
 from django.forms import BaseInlineFormSet
@@ -21,8 +23,9 @@ from ietf.meeting.models import Session, Meeting, Schedule, countries, timezones
 from ietf.meeting.helpers import get_next_interim_number, make_materials_directories
 from ietf.meeting.helpers import is_interim_meeting_approved, get_next_agenda_name
 from ietf.message.models import Message
+from ietf.name.models import TimeSlotTypeName, SessionPurposeName
 from ietf.person.models import Person
-from ietf.utils.fields import DatepickerDateField, DurationField, MultiEmailField
+from ietf.utils.fields import DatepickerDateField, DurationField, MultiEmailField, DatepickerSplitDateTimeWidget
 from ietf.utils.validators import ( validate_file_size, validate_mime_type,
     validate_file_extension, validate_no_html_frame)
 
@@ -44,7 +47,8 @@ class GroupModelChoiceField(forms.ModelChoiceField):
         return obj.acronym
 
 class CustomDurationField(DurationField):
-    '''Custom DurationField to display as HH:MM (no seconds)'''
+    """Custom DurationField to display as HH:MM (no seconds)"""
+    widget = forms.TextInput(dict(placeholder='HH:MM'))
     def prepare_value(self, value):
         if isinstance(value, datetime.timedelta):
             return duration_string(value)
@@ -417,3 +421,212 @@ class SwapTimeslotsForm(forms.Form):
         self.fields['origin_timeslot'].queryset = meeting.timeslot_set.all()
         self.fields['target_timeslot'].queryset = meeting.timeslot_set.all()
         self.fields['rooms'].queryset = meeting.room_set.all()
+
+
+class TimeSlotDurationField(CustomDurationField):
+    """Duration field for TimeSlot edit / create forms"""
+    default_validators=[
+        validators.MinValueValidator(datetime.timedelta(seconds=0)),
+        validators.MaxValueValidator(datetime.timedelta(hours=12)),
+    ]
+
+    def __init__(self, **kwargs):
+        kwargs.setdefault('help_text', 'Duration of timeslot in hours and minutes')
+        super().__init__(**kwargs)
+
+
+class TimeSlotEditForm(forms.ModelForm):
+    class Meta:
+        model = TimeSlot
+        fields = ('name', 'type', 'time', 'duration', 'show_location', 'location')
+        field_classes = dict(
+            time=forms.SplitDateTimeField,
+            duration=TimeSlotDurationField
+        )
+        widgets = dict(
+            time=DatepickerSplitDateTimeWidget,
+        )
+
+    def __init__(self, *args, **kwargs):
+        super(TimeSlotEditForm, self).__init__(*args, **kwargs)
+        self.fields['location'].queryset = self.instance.meeting.room_set.all()
+
+
+class TimeSlotCreateForm(forms.Form):
+    name = forms.CharField(max_length=255)
+    type = forms.ModelChoiceField(queryset=TimeSlotTypeName.objects.all(), initial='regular')
+    days = forms.TypedMultipleChoiceField(
+        label='Meeting days',
+        widget=forms.CheckboxSelectMultiple,
+        coerce=lambda s: datetime.date.fromordinal(int(s)),
+        empty_value=None,
+        required=False
+    )
+    other_date = DatepickerDateField(
+        required=False,
+        help_text='Optional date outside the official meeting dates',
+        date_format="yyyy-mm-dd",
+        picker_settings={"autoclose": "1"},
+    )
+
+    time = forms.TimeField(
+        help_text='Time to create timeslot on each selected date',
+        widget=forms.TimeInput(dict(placeholder='HH:MM'))
+    )
+    duration = TimeSlotDurationField()
+    show_location = forms.BooleanField(required=False, initial=True)
+    locations = forms.ModelMultipleChoiceField(
+        queryset=Room.objects.none(),
+        widget=forms.CheckboxSelectMultiple,
+    )
+
+    def __init__(self, meeting, *args, **kwargs):
+        super(TimeSlotCreateForm, self).__init__(*args, **kwargs)
+
+        meeting_days = [
+            meeting.date + datetime.timedelta(days=n)
+            for n in range(meeting.days)
+        ]
+
+        # Fill in dynamic field properties
+        self.fields['days'].choices = self._day_choices(meeting_days)
+        self.fields['other_date'].widget.attrs['data-date-default-view-date'] = meeting.date
+        self.fields['other_date'].widget.attrs['data-date-dates-disabled'] = ','.join(
+            d.isoformat() for d in meeting_days
+        )
+        self.fields['locations'].queryset = meeting.room_set.order_by('name')
+
+    def clean_other_date(self):
+        # Because other_date is not required, failed field validation does not automatically
+        # invalidate the form. It should, otherwise a typo may be silently ignored.
+        if self.data.get('other_date') and not self.cleaned_data.get('other_date'):
+            raise ValidationError('Enter a valid date or leave field blank.')
+        return self.cleaned_data.get('other_date', None)
+
+    def clean(self):
+        # Merge other_date and days fields
+        try:
+            other_date = self.cleaned_data.pop('other_date')
+        except KeyError:
+            other_date = None
+
+        self.cleaned_data['days'] = self.cleaned_data.get('days') or []
+        if other_date is not None:
+            self.cleaned_data['days'].append(other_date)
+        if len(self.cleaned_data['days']) == 0:
+            self.add_error('days', ValidationError('Please select a day or specify a date'))
+
+    @staticmethod
+    def _day_choices(days):
+        """Generates an iterable of value, label pairs for a choice field
+
+        Uses toordinal() to represent dates - would prefer to use isoformat(),
+        but fromisoformat() is not available in python 3.6..
+        """
+        choices = [
+            (str(day.toordinal()), day.strftime('%A ({})'.format(day.isoformat())))
+            for day in days
+        ]
+        return choices
+
+
+class DurationChoiceField(forms.ChoiceField):
+    duration_choices = (('3600', '60 minutes'), ('7200', '120 minutes'))
+
+    # todo expand range of choices and make configurable
+    def __init__(self, *args, **kwargs):
+        super().__init__(
+            choices=(('','--Please select'), *self.duration_choices),
+            *args, **kwargs,
+        )
+
+    def prepare_value(self, value):
+        """Converts incoming value into string used for the option value"""
+        if value:
+            return str(int(value.total_seconds())) if hasattr(value, 'total_seconds') else str(value)
+        return ''
+
+    def clean(self, value):
+        """Convert option value string back to a timedelta"""
+        val = super().clean(value)
+        if val == '':
+            return None
+        return datetime.timedelta(seconds=int(val))
+
+
+class SessionDetailsForm(forms.ModelForm):
+    requested_duration = DurationChoiceField()
+
+    def __init__(self, group, *args, **kwargs):
+        session_purposes = group.features.session_purposes
+        kwargs.setdefault('initial', {})
+        kwargs['initial'].setdefault(
+            'purpose',
+            session_purposes[0] if len(session_purposes) > 0 else None,
+        )
+        super().__init__(*args, **kwargs)
+
+        self.fields['type'].widget.attrs.update({
+            'data-allowed-options': json.dumps({
+                purpose.slug: list(purpose.timeslot_types)
+                for purpose in SessionPurposeName.objects.all()
+            }),
+        })
+        self.fields['purpose'].queryset = SessionPurposeName.objects.filter(pk__in=session_purposes)
+
+    class Meta:
+        model = Session
+        fields = ('name', 'short', 'purpose', 'type', 'requested_duration', 'remote_instructions')
+        labels = {'requested_duration': 'Length'}
+
+    class Media:
+        js = ('ietf/js/meeting/session_details_form.js',)
+
+
+class SessionDetailsInlineFormset(forms.BaseInlineFormSet):
+    def __init__(self, group, meeting, queryset=None, *args, **kwargs):
+        self._meeting = meeting
+        self.created_instances = []
+
+        # Restrict sessions to the meeting and group. The instance
+        # property handles one of these for free.
+        kwargs['instance'] = group
+        if queryset is None:
+            queryset = Session._default_manager
+        if self._meeting.pk is not None:
+            queryset = queryset.filter(meeting=self._meeting)
+        else:
+            queryset = queryset.none()
+        kwargs['queryset'] = queryset.not_deleted()
+
+        kwargs.setdefault('form_kwargs', {})
+        kwargs['form_kwargs'].update({'group': group})
+
+        super().__init__(*args, **kwargs)
+
+    def save_new(self, form, commit=True):
+        form.instance.meeting = self._meeting
+        super().save_new(form, commit)
+
+    def save(self, commit=True):
+        existing_instances = set(form.instance for form in self.forms if form.instance.pk)
+        saved = super().save(commit)
+        self.created_instances = [inst for inst in saved if inst not in existing_instances]
+        return saved
+
+    @property
+    def forms_to_keep(self):
+        """Get the not-deleted forms"""
+        return [f for f in self.forms if f not in self.deleted_forms]
+
+SessionDetailsFormSet = forms.inlineformset_factory(
+    Group,
+    Session,
+    formset=SessionDetailsInlineFormset,
+    form=SessionDetailsForm,
+    can_delete=True,
+    can_order=False,
+    min_num=1,
+    max_num=3,
+    extra=3,
+)
\ No newline at end of file
diff --git a/ietf/meeting/helpers.py b/ietf/meeting/helpers.py
index 508caccea..3fd4417a1 100644
--- a/ietf/meeting/helpers.py
+++ b/ietf/meeting/helpers.py
@@ -30,10 +30,13 @@ from ietf.mailtrigger.utils import gather_address_lists
 from ietf.person.models  import Person
 from ietf.meeting.models import Meeting, Schedule, TimeSlot, SchedTimeSessAssignment, ImportantDate, SchedulingEvent, Session
 from ietf.meeting.utils import session_requested_by, add_event_info_to_session_qs
-from ietf.name.models import ImportantDateName
+from ietf.name.models import ImportantDateName, SessionPurposeName
+from ietf.utils import log
 from ietf.utils.history import find_history_active_at, find_history_replacements_active_at
 from ietf.utils.mail import send_mail
 from ietf.utils.pipe import pipe
+from ietf.utils.text import xslugify
+
 
 def find_ads_for_meeting(meeting):
     ads = []
@@ -229,7 +232,7 @@ def preprocess_assignments_for_agenda(assignments_queryset, meeting, extra_prefe
 
             l = sessions_for_groups.get((a.session.group, a.session.type_id), [])
             a.session.order_number = l.index(a) + 1 if a in l else 0
-            
+
     parents = Group.objects.filter(pk__in=parent_id_set)
     parent_replacements = find_history_replacements_active_at(parents, meeting_time)
 
@@ -253,54 +256,459 @@ def preprocess_assignments_for_agenda(assignments_queryset, meeting, extra_prefe
 
     return assignments
 
-def is_regular_agenda_filter_group(group):
-    """Should this group appear in the 'regular' agenda filter button lists?"""
-    return group.type_id in ('wg', 'rg', 'ag', 'rag', 'iab', 'program')
 
-def tag_assignments_with_filter_keywords(assignments):
-    """Add keywords for agenda filtering
-    
-    Keywords are all lower case.
+class AgendaKeywordTool:
+    """Base class for agenda keyword-related organizers
+
+    The purpose of this class is to hold utility methods and data needed by keyword generation
+    helper classes. It ensures consistency of, e.g., definitions of when to use legacy keywords or what
+    timeslot types should be used to define filters.
     """
-    for a in assignments:
-        a.filter_keywords = {a.timeslot.type.slug.lower()}
-        a.filter_keywords.update(filter_keywords_for_session(a.session))
-        a.filter_keywords = sorted(list(a.filter_keywords))
+    def __init__(self, *, assignments=None, sessions=None):
+        # n.b., single star argument means only keyword parameters are allowed when calling constructor
+        if assignments is not None and sessions is None:
+            self.assignments = assignments
+            self.sessions = [a.session for a in self.assignments if a.session]
+        elif sessions is not None and assignments is None:
+            self.assignments = None
+            self.sessions = sessions
+        else:
+            raise RuntimeError('Exactly one of assignments or sessions must be specified')
 
-def filter_keywords_for_session(session):
-    keywords = {session.type.slug.lower()}
-    group = getattr(session, 'historic_group', session.group)
-    if group is not None:
-        if group.state_id == 'bof':
-            keywords.add('bof')
-        keywords.add(group.acronym.lower())
-        specific_kw = filter_keyword_for_specific_session(session)
+        self.meeting = self.sessions[0].meeting if len(self.sessions) > 0 else None
+
+    def _use_legacy_keywords(self):
+        """Should legacy keyword handling be used for this meeting?"""
+        # Only IETF meetings need legacy handling. These are identified
+        # by having a purely numeric meeting.number.
+        return (self.meeting is not None
+                and self.meeting.number.isdigit()
+                and int(self.meeting.number) <= settings.MEETING_LEGACY_OFFICE_HOURS_END)
+
+    # Helper methods
+    @staticmethod
+    def _get_group(s):
+        """Get group of a session, handling historic groups"""
+        return getattr(s, 'historic_group', s.group)
+
+    def _get_group_parent(self, s):
+        """Get parent of a group or parent of a session's group, handling historic groups"""
+        g = self._get_group(s) if isinstance(s, Session) else s  # accept a group or a session arg
+        return getattr(g, 'historic_parent', g.parent)
+
+    def _purpose_keyword(self, purpose):
+        """Get the keyword corresponding to a session purpose"""
+        return purpose.slug.lower()
+
+    def _group_keyword(self, group):
+        """Get the keyword corresponding to a session group"""
+        return group.acronym.lower()
+
+    def _session_name_keyword(self, session):
+        """Get the keyword identifying a session by name"""
+        return xslugify(session.name) if session.name else None
+
+    @property
+    def filterable_purposes(self):
+        return SessionPurposeName.objects.exclude(slug='session').order_by('name')
+
+
+class AgendaFilterOrganizer(AgendaKeywordTool):
+    """Helper class to organize agenda filters given a list of assignments or sessions
+
+    Either assignments or sessions must be specified (but not both). Keywords should be applied
+    to these items before calling either of the 'get_' methods, otherwise some special filters
+    may not be included (e.g., 'BoF' or 'Plenary'). If historic_group and/or historic_parent
+    attributes are present, these will be used instead of group/parent.
+
+    The organizer will process its inputs once, when one of its get_ methods is first called.
+
+    Terminology:
+      * column: group of related buttons, usually with a heading button.
+      * heading: button at the top of a column, e.g. an area. Has a keyword that applies to all in its column.
+      * category: a set of columns displayed as separate from other categories
+      * group filters: filters whose keywords derive from the group owning the session, such as for working groups
+      * non-group filters: filters whose keywords come from something other than a session's group
+      * special filters: group filters of type "special" that have no heading, end up in the catch-all column
+      * extra filters: ad hoc filters created based on the extra_labels list, go in the catch-all column
+      * catch-all column: column with no heading where extra filters and special filters are gathered
+    """
+    # group acronyms in this list will never be used as filter buttons
+    exclude_acronyms = ('iesg', 'ietf', 'secretariat')
+    # extra keywords to include in the no-heading column if they apply to any sessions
+    extra_labels = ('BoF', 'Plenary')
+    # group types whose acronyms should be word-capitalized
+    capitalized_group_types = ('team',)
+    # group types whose acronyms should be all-caps
+    uppercased_group_types = ('area', 'ietf', 'irtf')
+    # check that the group labeling sets are disjoint
+    assert(set(capitalized_group_types).isdisjoint(uppercased_group_types))
+    # group acronyms that need special handling
+    special_group_labels = dict(edu='EDU', iepg='IEPG')
+
+    def __init__(self, *, single_category=False, **kwargs):
+        super(AgendaFilterOrganizer, self).__init__(**kwargs)
+        self.single_category = single_category
+        # filled in when _organize_filters() is called
+        self.filter_categories = None
+        self.special_filters = None
+
+    def get_non_area_keywords(self):
+        """Get list of any 'non-area' (aka 'special') keywords
+
+        These are the keywords corresponding to the right-most, headingless button column.
+        """
+        if self.special_filters is None:
+            self._organize_filters()
+        return [sf['keyword'] for sf in self.special_filters['children']]
+
+    def get_filter_categories(self):
+        """Get a list of filter categories
+
+        If single_category is True, this will be a list with one element. Otherwise it
+        may have multiple elements. Each element is a list of filter columns.
+        """
+        if self.filter_categories is None:
+            self._organize_filters()
+        return self.filter_categories
+
+    def _organize_filters(self):
+        """Process inputs to construct and categorize filter lists"""
+        headings, special = self._group_filter_headings()
+        self.filter_categories = self._categorize_group_filters(headings)
+
+        # Create an additional category with non-group filters and special/extra filters
+        non_group_category = self._non_group_filters()
+
+        # special filters include self.extra_labels and any 'special' group filters
+        self.special_filters = self._extra_filters()
+        for g in special:
+            self.special_filters['children'].append(self._group_filter_entry(g))
+        if len(self.special_filters['children']) > 0:
+            self.special_filters['children'].sort(key=self._group_sort_key)
+            non_group_category.append(self.special_filters)
+
+        # if we have any additional filters, add them
+        if len(non_group_category) > 0:
+            if self.single_category:
+                # if a single category is requested, just add them to that category
+                self.filter_categories[0].extend(non_group_category)
+            else:
+                # otherwise add these as a separate category
+                self.filter_categories.append(non_group_category)
+
+    def _group_filter_headings(self):
+        """Collect group-based filters
+
+        Output is a tuple (dict(group->set), set). The dict keys are groups to be used as headings
+        with sets of child groups as associated values. The set is 'special' groups that have no
+        heading group.
+        """
+        # groups in the schedule that have a historic_parent group
+        groups = set(self._get_group(s) for s in self.sessions
+                     if s
+                     and self._get_group(s))
+        log.assertion('len(groups) == len(set(g.acronym for g in groups))')  # no repeated acros
+
+        group_parents = set(self._get_group_parent(g) for g in groups if self._get_group_parent(g))
+        log.assertion('len(group_parents) == len(set(gp.acronym for gp in group_parents))')  # no repeated acros
+
+        all_groups = groups.union(group_parents)
+        all_groups.difference_update([g for g in all_groups if g.acronym in self.exclude_acronyms])
+        headings = {g: set()
+                    for g in all_groups
+                    if g.features.agenda_filter_type_id == 'heading'}
+        special = set(g for g in all_groups
+                      if g.features.agenda_filter_type_id == 'special')
+
+        for g in groups:
+            if g.features.agenda_filter_type_id == 'normal':
+                # normal filter group with a heading parent goes in that column
+                p = self._get_group_parent(g)
+                if p in headings:
+                    headings[p].add(g)
+                else:
+                    # normal filter group with no heading parent is 'special'
+                    special.add(g)
+
+        return headings, special
+
+    def _categorize_group_filters(self, headings):
+        """Categorize the group-based filters
+
+        Returns a list of one or more categories of filter columns. When single_category is True,
+        it will always be only one category.
+        """
+        area_category = []  # headings are area groups
+        non_area_category = []  # headings are non-area groups
+
+        for h in headings:
+            if h.type_id == 'area' or self.single_category:
+                area_category.append(self._group_filter_column(h, headings[h]))
+            else:
+                non_area_category.append(self._group_filter_column(h, headings[h]))
+        area_category.sort(key=self._group_sort_key)
+        if self.single_category:
+            return [area_category]
+        non_area_category.sort(key=self._group_sort_key)
+        return [area_category, non_area_category]
+
+    def _non_group_filters(self):
+        """Get list of non-group filter columns
+
+        Empty columns will be omitted.
+        """
+        if self.sessions is None:
+            sessions = [a.session for a in self.assignments]
+        else:
+            sessions = self.sessions
+
+        # Call legacy version for older meetings
+        if self._use_legacy_keywords():
+            return self._legacy_non_group_filters()
+
+        # Not using legacy version
+        filter_cols = []
+        for purpose in self.filterable_purposes:
+            # Map label to its keyword, discarding duplicate labels.
+            # This does what we want as long as sessions with the same
+            # name and purpose belong to the same group.
+            sessions_by_name = {
+                session.name: session
+                for session in sessions if session.purpose == purpose
+            }
+            if len(sessions_by_name) > 0:
+                # keyword needs to match what's tagged in filter_keywords_for_session()
+                heading_kw = self._purpose_keyword(purpose)
+                children = []
+                for name, session in sessions_by_name.items():
+                    children.append(self._filter_entry(
+                        label=name,
+                        keyword=self._session_name_keyword(session),
+                        toggled_by=[self._group_keyword(session.group)] if session.group else None,
+                        is_bof=False,
+                    ))
+                column = self._filter_column(
+                    label=purpose.name,
+                    keyword=heading_kw,
+                    children=children,
+                )
+                filter_cols.append(column)
+
+        return filter_cols
+
+    def _legacy_non_group_filters(self):
+        """Get list of non-group filters for older meetings
+
+        Returns a list of filter columns
+        """
+        if self.assignments is None:
+            return []  # can only use timeslot type when we have assignments
+
+        office_hours_items = set()
+        suffix = ' office hours'
+        for a in self.assignments:
+            if a.session.name.lower().endswith(suffix):
+                office_hours_items.add((a.session.name[:-len(suffix)].strip(), a.session.group))
+
+        headings = []
+        # currently we only do office hours
+        if len(office_hours_items) > 0:
+            column = self._filter_column(
+                label='Office Hours',
+                keyword='officehours',
+                children=[
+                    self._filter_entry(
+                        label=label,
+                        keyword=f'{label.lower().replace(" ", "")}-officehours',
+                        toggled_by=[self._group_keyword(group)] if group else None,
+                        is_bof=False,
+                    )
+                    for label, group in sorted(office_hours_items, key=lambda item: item[0].upper())
+                ])
+            headings.append(column)
+        return headings
+
+    def _extra_filters(self):
+        """Get list of filters corresponding to self.extra_labels"""
+        item_source = self.assignments or self.sessions or []
+        candidates = set(self.extra_labels)
+        return self._filter_column(
+            label=None,
+            keyword=None,
+            children=[
+                self._filter_entry(label=label, keyword=xslugify(label), toggled_by=[], is_bof=False)
+                for label in candidates if any(
+                    # Keep only those that will affect at least one session
+                    [label.lower() in item.filter_keywords for item in item_source]
+                )]
+        )
+
+    @staticmethod
+    def _filter_entry(label, keyword, is_bof, toggled_by=None):
+        """Construct a filter entry representation"""
+        # get our own copy of the list for toggled_by
+        if toggled_by is None:
+            toggled_by = []
+        if is_bof:
+            toggled_by = ['bof'] + toggled_by
+        return dict(
+            label=label,
+            keyword=keyword,
+            toggled_by=toggled_by,
+            is_bof=is_bof,
+        )
+
+    def _filter_column(self, label, keyword, children):
+        """Construct a filter column given a label, keyword, and list of child entries"""
+        entry = self._filter_entry(label, keyword, False)  # heading
+        entry['children'] = children
+        # all children should be controlled by the heading keyword
+        if keyword:
+            for child in children:
+                if keyword not in child['toggled_by']:
+                    child['toggled_by'] = [keyword] + child['toggled_by']
+        return entry
+
+    def _group_label(self, group):
+        """Generate a label for a group filter button"""
+        label = group.acronym
+        if label in self.special_group_labels:
+            return self.special_group_labels[label]
+        elif group.type_id in self.capitalized_group_types:
+            return label.capitalize()
+        elif group.type_id in self.uppercased_group_types:
+            return label.upper()
+        return label
+
+    def _group_filter_entry(self, group):
+        """Construct a filter_entry for a group filter button"""
+        return self._filter_entry(
+            label=self._group_label(group),
+            keyword=self._group_keyword(group),
+            toggled_by=[self._group_keyword(group.parent)] if group.parent else None,
+            is_bof=group.is_bof(),
+        )
+
+    def _group_filter_column(self, heading, children):
+        """Construct a filter column given a heading group and a list of its child groups"""
+        return self._filter_column(
+            label=None if heading is None else self._group_label(heading),
+            keyword=self._group_keyword(heading),
+            children=sorted([self._group_filter_entry(g) for g in children], key=self._group_sort_key),
+        )
+
+    @staticmethod
+    def _group_sort_key(g):
+        return 'zzzzzzzz' if g is None else g['label'].upper()  # sort blank to the end
+
+
+class AgendaKeywordTagger(AgendaKeywordTool):
+    """Class for applying keywords to agenda timeslot assignments.
+
+    This is the other side of the agenda filtering: AgendaFilterOrganizer generates the
+    filter buttons, this applies keywords to the entries being filtered.
+    """
+    def apply(self):
+        """Apply tags to sessions / assignments"""
+        if self.assignments is not None:
+            self._tag_assignments_with_filter_keywords()
+        else:
+            self._tag_sessions_with_filter_keywords()
+
+    def apply_session_keywords(self):
+        """Tag each item with its session-specific keyword"""
+        if self.assignments is not None:
+            for a in self.assignments:
+                a.session_keyword = self.filter_keyword_for_specific_session(a.session)
+        else:
+            for s in self.sessions:
+                s.session_keyword = self.filter_keyword_for_specific_session(s)
+
+    def _is_regular_agenda_filter_group(self, group):
+        """Should this group appear in the 'regular' agenda filter button lists?"""
+        parent = self._get_group_parent(group)
+        return (
+                group.features.agenda_filter_type_id == 'normal'
+                and parent
+                and parent.features.agenda_filter_type_id == 'heading'
+        )
+
+    def _tag_assignments_with_filter_keywords(self):
+        """Add keywords for agenda filtering
+
+        Keywords are all lower case.
+        """
+        for a in self.assignments:
+            a.filter_keywords = {a.slot_type().slug.lower()}
+            a.filter_keywords.update(self._filter_keywords_for_assignment(a))
+            a.filter_keywords = sorted(list(a.filter_keywords))
+
+    def _tag_sessions_with_filter_keywords(self):
+        for s in self.sessions:
+            s.filter_keywords = self._filter_keywords_for_session(s)
+            s.filter_keywords = sorted(list(s.filter_keywords))
+
+    @staticmethod
+    def _legacy_extra_session_keywords(session):
+        """Get extra keywords for a session at a legacy meeting"""
+        office_hours_match = re.match(r'^ *\w+(?: +\w+)* +office hours *$', session.name, re.IGNORECASE)
+        if office_hours_match is not None:
+            suffix = 'officehours'
+            return [
+                'officehours',
+                session.name.lower().replace(' ', '')[:-len(suffix)] + '-officehours',
+            ]
+        return []
+
+    def _filter_keywords_for_session(self, session):
+        keywords = set()
+        if session.purpose in self.filterable_purposes:
+            keywords.add(self._purpose_keyword(session.purpose))
+
+        group = self._get_group(session)
+        if group is not None:
+            if group.state_id == 'bof':
+                keywords.add('bof')
+            keywords.add(self._group_keyword(group))
+        specific_kw = self.filter_keyword_for_specific_session(session)
         if specific_kw is not None:
             keywords.add(specific_kw)
-        area = getattr(group, 'historic_parent', group.parent)
+
+        kw = self._session_name_keyword(session)
+        if kw:
+            keywords.add(kw)
 
         # Only sessions belonging to "regular" groups should respond to the
         # parent group filter keyword (often the 'area'). This must match
         # the test used by the agenda() view to decide whether a group
         # gets an area or non-area filter button.
-        if is_regular_agenda_filter_group(group) and area is not None:
-            keywords.add(area.acronym.lower())
-    office_hours_match = re.match(r'^ *\w+(?: +\w+)* +office hours *$', session.name, re.IGNORECASE)
-    if office_hours_match is not None:
-        keywords.update(['officehours', session.name.lower().replace(' ', '')])
-    return sorted(list(keywords))
+        if self._is_regular_agenda_filter_group(group):
+            area = self._get_group_parent(group)
+            if area is not None:
+                keywords.add(self._group_keyword(area))
 
-def filter_keyword_for_specific_session(session):
-    """Get keyword that identifies a specific session
+        if self._use_legacy_keywords():
+            keywords.update(self._legacy_extra_session_keywords(session))
+
+        return sorted(keywords)
+
+    def _filter_keywords_for_assignment(self, assignment):
+        keywords = self._filter_keywords_for_session(assignment.session)
+        return sorted(keywords)
+
+    def filter_keyword_for_specific_session(self, session):
+        """Get keyword that identifies a specific session
+
+        Returns None if the session cannot be selected individually.
+        """
+        group = self._get_group(session)
+        if group is None:
+            return None
+        kw = self._group_keyword(group)  # start with this
+        token = session.docname_token_only_for_multiple()
+        return kw if token is None else '{}-{}'.format(kw, token)
 
-    Returns None if the session cannot be selected individually.
-    """
-    group = getattr(session, 'historic_group', session.group)
-    if group is None:
-        return None
-    kw = group.acronym.lower()  # start with this
-    token = session.docname_token_only_for_multiple()
-    return kw if token is None else '{}-{}'.format(kw, token)
 
 def read_session_file(type, num, doc):
     # XXXX FIXME: the path fragment in the code below should be moved to
diff --git a/ietf/meeting/migrations/0049_session_purpose.py b/ietf/meeting/migrations/0049_session_purpose.py
new file mode 100644
index 000000000..be863905b
--- /dev/null
+++ b/ietf/meeting/migrations/0049_session_purpose.py
@@ -0,0 +1,21 @@
+# Generated by Django 2.2.24 on 2021-09-16 18:04
+
+from django.db import migrations
+import django.db.models.deletion
+import ietf.utils.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('name', '0036_populate_sessionpurposename'),
+        ('meeting', '0048_auto_20211008_0907'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='session',
+            name='purpose',
+            field=ietf.utils.models.ForeignKey(help_text='Purpose of the session', null=True, on_delete=django.db.models.deletion.CASCADE, to='name.SessionPurposeName'),
+        ),
+    ]
diff --git a/ietf/meeting/models.py b/ietf/meeting/models.py
index 24874817b..445ea49e2 100644
--- a/ietf/meeting/models.py
+++ b/ietf/meeting/models.py
@@ -26,6 +26,7 @@ from django.conf import settings
 # mostly used by json_dict()
 #from django.template.defaultfilters import slugify, date as date_format, time as time_format
 from django.template.defaultfilters import date as date_format
+from django.urls import reverse as urlreverse
 from django.utils.text import slugify
 from django.utils.safestring import mark_safe
 
@@ -36,6 +37,7 @@ from ietf.group.utils import can_manage_materials
 from ietf.name.models import (
     MeetingTypeName, TimeSlotTypeName, SessionStatusName, ConstraintName, RoomResourceName,
     ImportantDateName, TimerangeName, SlideSubmissionStatusName, ProceedingsMaterialTypeName,
+    SessionPurposeName,
 )
 from ietf.person.models import Person
 from ietf.utils.decorators import memoize
@@ -338,6 +340,13 @@ class Meeting(models.Model):
             }
 
     def build_timeslices(self):
+        """Get unique day/time/timeslot data for meeting
+        
+        Returns a list of days, time intervals for each day, and timeslots for each day,
+        with repeated days/time intervals removed. Ignores timeslots that do not have a
+        location. The slots return value contains only one TimeSlot for each distinct
+        time interval.
+        """
         days = []          # the days of the meetings
         time_slices = {}   # the times on each day
         slots = {}
@@ -359,8 +368,9 @@ class Meeting(models.Model):
 
         days.sort()
         for ymd in time_slices:
+            # Make sure these sort the same way
             time_slices[ymd].sort()
-            slots[ymd].sort(key=lambda x: x.time)
+            slots[ymd].sort(key=lambda x: (x.time, x.duration))
         return days,time_slices,slots
 
     # this functions makes a list of timeslices and rooms, and
@@ -487,6 +497,18 @@ class Room(models.Model):
             'capacity':             self.capacity,
             }
     # floorplan support
+    def floorplan_url(self):
+        mtg_num = self.meeting.get_number()
+        if not mtg_num:
+            return None
+        elif mtg_num <= settings.FLOORPLAN_LAST_LEGACY_MEETING:
+            base_url = settings.FLOORPLAN_LEGACY_BASE_URL.format(meeting=self.meeting)
+        elif self.floorplan:
+            base_url = urlreverse('ietf.meeting.views.floor_plan', kwargs=dict(num=mtg_num))
+        else:
+            return None
+        return f'{base_url}?room={xslugify(self.name)}'
+
     def left(self):
         return min(self.x1, self.x2) if (self.x1 and self.x2) else 0
     def top(self):
@@ -878,6 +900,14 @@ class SchedTimeSessAssignment(models.Model):
         else:
             return None
 
+    def meeting(self):
+        """Get the meeting to which this assignment belongs"""
+        return self.session.meeting
+
+    def slot_type(self):
+        """Get the TimeSlotTypeName that applies to this assignment"""
+        return self.timeslot.type
+
     def json_url(self):
         if not hasattr(self, '_cached_json_url'):
             self._cached_json_url =  "/meeting/%s/agenda/%s/%s/session/%u.json" % (
@@ -928,12 +958,12 @@ class SchedTimeSessAssignment(models.Model):
 
             g = getattr(self.session, "historic_group", None) or self.session.group
 
-            if self.timeslot.type_id in ('break', 'reg', 'other'):
+            if self.timeslot.type.slug in ('break', 'reg', 'other'):
                 components.append(g.acronym)
                 components.append(slugify(self.session.name))
 
-            if self.timeslot.type_id in ('regular', 'plenary'):
-                if self.timeslot.type_id == "plenary":
+            if self.timeslot.type.slug in ('regular', 'plenary'):
+                if self.timeslot.type.slug == "plenary":
                     components.append("1plenary")
                 else:
                     p = getattr(g, "historic_parent", None) or g.parent
@@ -1098,6 +1128,13 @@ class SessionQuerySet(models.QuerySet):
         """
         return self.with_current_status().exclude(current_status__in=Session.CANCELED_STATUSES)
 
+    def not_deleted(self):
+        """Queryset containing all sessions not deleted
+
+        Results annotated with current_status
+        """
+        return self.with_current_status().exclude(current_status='deleted')
+
     def that_can_meet(self):
         """Queryset containing sessions that can meet
         
@@ -1120,6 +1157,7 @@ class Session(models.Model):
     meeting = ForeignKey(Meeting)
     name = models.CharField(blank=True, max_length=255, help_text="Name of session, in case the session has a purpose rather than just being a group meeting.")
     short = models.CharField(blank=True, max_length=32, help_text="Short version of 'name' above, for use in filenames.")
+    purpose = ForeignKey(SessionPurposeName, null=True, help_text='Purpose of the session')
     type = ForeignKey(TimeSlotTypeName)
     group = ForeignKey(Group)    # The group type historically determined the session type.  BOFs also need to be added as a group. Note that not all meeting requests have a natural group to associate with.
     joint_with_groups = models.ManyToManyField(Group, related_name='sessions_joint_in',blank=True)
diff --git a/ietf/meeting/templatetags/agenda_custom_tags.py b/ietf/meeting/templatetags/agenda_custom_tags.py
index 30b18e0f3..1b64f9adf 100644
--- a/ietf/meeting/templatetags/agenda_custom_tags.py
+++ b/ietf/meeting/templatetags/agenda_custom_tags.py
@@ -5,6 +5,8 @@
 from django import template
 from django.urls import reverse
 
+from ietf.utils.text import xslugify
+
 register = template.Library()
 
 
@@ -67,4 +69,86 @@ def webcal_url(context, viewname, *args, **kwargs):
     return 'webcal://{}{}'.format(
         context.request.get_host(),
         reverse(viewname, args=args, kwargs=kwargs)
-    )
\ No newline at end of file
+    )
+
+@register.simple_tag
+def assignment_display_name(assignment):
+    """Get name for an assignment"""
+    if assignment.session.type.slug == 'session' and assignment.session.historic_group:
+        return assignment.session.historic_group.name
+    return assignment.session.name or assignment.timeslot.name
+
+
+class AnchorNode(template.Node):
+    """Template node for a conditionally-included anchor
+
+    If self.resolve_url() returns a URL, the contents of the nodelist will be rendered inside
+    <a href="{{ self.resolve_url() }}"> ... </a>. If it returns None, the <a> will be omitted.
+    The contents will be rendered in either case.
+    """
+    def __init__(self, nodelist):
+        self.nodelist = nodelist
+
+    def resolve_url(self, context):
+        raise NotImplementedError('Subclasses must define this method')
+
+    def render(self, context):
+        url = self.resolve_url(context)
+        if url:
+            return '<a href="{}">{}</a>'.format(url, self.nodelist.render(context))
+        else:
+            return self.nodelist.render(context)
+
+
+class AgendaAnchorNode(AnchorNode):
+    """Template node for the agenda_anchor tag"""
+    def __init__(self, session, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.session = template.Variable(session)
+
+    def resolve_url(self, context):
+        sess = self.session.resolve(context)
+        agenda = sess.agenda()
+        if agenda:
+            return agenda.get_href()
+        return None
+
+
+@register.tag
+def agenda_anchor(parser, token):
+    """Block tag that wraps its content in a link to the session agenda, if any"""
+    try:
+        tag_name, sess_var = token.split_contents()
+    except ValueError:
+        raise template.TemplateSyntaxError('agenda_anchor requires a single argument')
+    nodelist = parser.parse(('end_agenda_anchor',))
+    parser.delete_first_token()  # delete the end tag
+    return AgendaAnchorNode(sess_var, nodelist)
+
+
+class LocationAnchorNode(AnchorNode):
+    """Template node for the location_anchor tag"""
+    def __init__(self, timeslot, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.timeslot = template.Variable(timeslot)
+
+    def resolve_url(self, context):
+        ts = self.timeslot.resolve(context)
+        if ts.show_location and ts.location:
+            return ts.location.floorplan_url()
+        return None
+
+@register.tag
+def location_anchor(parser, token):
+    """Block tag that wraps its content in a link to the timeslot location
+
+    If the timeslot has no location information or is marked with show_location=False,
+    the anchor tag is omitted.
+    """
+    try:
+        tag_name, ts_var = token.split_contents()
+    except ValueError:
+        raise template.TemplateSyntaxError('location_anchor requires a single argument')
+    nodelist = parser.parse(('end_location_anchor',))
+    parser.delete_first_token()  # delete the end tag
+    return LocationAnchorNode(ts_var, nodelist)
diff --git a/ietf/meeting/templatetags/tests.py b/ietf/meeting/templatetags/tests.py
new file mode 100644
index 000000000..eb85fd47b
--- /dev/null
+++ b/ietf/meeting/templatetags/tests.py
@@ -0,0 +1,20 @@
+# Copyright The IETF Trust 2009-2020, All Rights Reserved
+# -*- coding: utf-8 -*-
+
+from ietf.meeting.templatetags.agenda_custom_tags import AnchorNode
+from ietf.utils.test_utils import TestCase
+
+
+class AgendaCustomTagsTests(TestCase):
+    def test_anchor_node_subclasses_implement_resolve_url(self):
+        """Check that AnchorNode subclasses implement the resolve_url method
+
+        This will only catch errors in subclasses defined in the agenda_custom_tags.py module.
+        """
+        for subclass in AnchorNode.__subclasses__():
+            try:
+                subclass.resolve_url(None, None)
+            except NotImplementedError:
+                self.fail(f'{subclass.__name__} must implement resolve_url() method')
+            except:
+                pass  # any other failure ok since we used garbage inputs
diff --git a/ietf/meeting/tests_helpers.py b/ietf/meeting/tests_helpers.py
index 1e7705c5e..b37118a86 100644
--- a/ietf/meeting/tests_helpers.py
+++ b/ietf/meeting/tests_helpers.py
@@ -1,78 +1,96 @@
 # Copyright The IETF Trust 2020, All Rights Reserved
 # -*- coding: utf-8 -*-
+import copy
+
+from django.test import override_settings
+
 from ietf.group.factories import GroupFactory
-from ietf.meeting.factories import SessionFactory, MeetingFactory
-from ietf.meeting.helpers import tag_assignments_with_filter_keywords
+from ietf.group.models import Group
+from ietf.meeting.factories import SessionFactory, MeetingFactory, TimeSlotFactory
+from ietf.meeting.helpers import AgendaFilterOrganizer, AgendaKeywordTagger
+from ietf.meeting.test_data import make_meeting_test_data
 from ietf.utils.test_utils import TestCase
 
 
-class HelpersTests(TestCase):
-    def do_test_tag_assignments_with_filter_keywords(self, bof=False, historic=None):
+# override the legacy office hours setting to guarantee consistency with the tests
+@override_settings(MEETING_LEGACY_OFFICE_HOURS_END=111)
+class AgendaKeywordTaggerTests(TestCase):
+    def do_test_tag_assignments_with_filter_keywords(self, meeting_num, bof=False, historic=None):
         """Assignments should be tagged properly
         
         The historic param can be None, group, or parent, to specify whether to test
         with no historic_group, a historic_group but no historic_parent, or both.
         """
-        meeting_types = ['regular', 'plenary']
+        session_types = ['regular', 'plenary']
+        # decide whether meeting should use legacy keywords (for office hours)
+        legacy_keywords = meeting_num <= 111
+
+        # create meeting and groups
+        meeting = MeetingFactory(type_id='ietf', number=meeting_num)
         group_state_id = 'bof' if bof else 'active'
         group = GroupFactory(state_id=group_state_id)
-        historic_group = GroupFactory(state_id=group_state_id)
-        historic_parent = GroupFactory(type_id='area')
 
-        if historic == 'parent':
-            historic_group.historic_parent = historic_parent
-
-        # Create meeting and sessions
-        meeting = MeetingFactory()
-        for meeting_type in meeting_types:
-            sess = SessionFactory(group=group, meeting=meeting, type_id=meeting_type)
-            ts = sess.timeslotassignments.first().timeslot
-            ts.type = sess.type
-            ts.save()
-
-        # Create an office hours session in the group's area (i.e., parent). This is not
-        # currently really needed, but will protect against areas and groups diverging
-        # in a way that breaks keywording.
-        office_hours = SessionFactory(
-            name='some office hours',
-            group=group.parent,
-            meeting=meeting,
-            type_id='other'
-        )
-        ts = office_hours.timeslotassignments.first().timeslot
-        ts.type = office_hours.type
-        ts.save()
-
-        assignments = meeting.schedule.assignments.all()
-        orig_num_assignments = len(assignments)
-
-        # Set up historic groups if needed
+        # Set up the historic group and parent if needed. Keep track of these as expected_*
+        # for later reference. If not using historic group or parent, fall back to the non-historic
+        # groups.
         if historic:
-            for a in assignments:
-                if a.session != office_hours:
-                    a.session.historic_group = historic_group
-
-        # Execute the method under test
-        tag_assignments_with_filter_keywords(assignments)
-
-        # Assert expected results
-        self.assertEqual(len(assignments), orig_num_assignments, 'Should not change number of assignments')
-
-        if historic:
-            expected_group = historic_group
-            expected_area = historic_parent if historic == 'parent' else historic_group.parent
+            expected_group = GroupFactory(state_id=group_state_id)
+            if historic == 'parent':
+                expected_area = GroupFactory(type_id='area')
+                expected_group.historic_parent = expected_area
+            else:
+                expected_area = expected_group.parent
         else:
             expected_group = group
             expected_area = group.parent
 
+        # create the ordinary sessions
+        for session_type in session_types:
+            sess = SessionFactory(group=group, meeting=meeting, type_id=session_type, add_to_schedule=False)
+            sess.timeslotassignments.create(
+                timeslot=TimeSlotFactory(meeting=meeting, type_id=session_type),
+                schedule=meeting.schedule,
+            )
+
+        # Create an office hours session in the group's area (i.e., parent). Handle this separately
+        # from other session creation to test legacy office hours naming.
+        office_hours = SessionFactory(
+            name='some office hours',
+            group=Group.objects.get(acronym='iesg') if legacy_keywords else expected_area,
+            meeting=meeting,
+            type_id='other' if legacy_keywords else 'officehours',
+            add_to_schedule=False,
+        )
+        office_hours.timeslotassignments.create(
+            timeslot=TimeSlotFactory(meeting=meeting, type_id=office_hours.type_id),
+            schedule=meeting.schedule,
+        )
+
+        assignments = meeting.schedule.assignments.all()
+        orig_num_assignments = len(assignments)
+
+        # Set up historic groups if needed. We've already set the office hours group properly
+        # so skip that session. The expected_group will already have its historic_parent set
+        # if historic == 'parent'
+        if historic:
+            for a in assignments:
+                if a.session != office_hours:
+                    a.session.historic_group = expected_group
+
+        # Execute the method under test
+        AgendaKeywordTagger(assignments=assignments).apply()
+
+        # Assert expected results
+        self.assertEqual(len(assignments), orig_num_assignments, 'Should not change number of assignments')
+
         for assignment in assignments:
-            expected_filter_keywords = {assignment.timeslot.type.slug, assignment.session.type.slug}
+            expected_filter_keywords = {assignment.slot_type().slug, assignment.session.type.slug}
 
             if assignment.session == office_hours:
                 expected_filter_keywords.update([
-                    group.parent.acronym,
+                    office_hours.group.acronym,
                     'officehours',
-                    'someofficehours',
+                    'some-officehours' if legacy_keywords else '{}-officehours'.format(expected_area.acronym),
                 ])
             else:
                 expected_filter_keywords.update([
@@ -92,9 +110,149 @@ class HelpersTests(TestCase):
             )
 
     def test_tag_assignments_with_filter_keywords(self):
-        self.do_test_tag_assignments_with_filter_keywords()
-        self.do_test_tag_assignments_with_filter_keywords(historic='group')
-        self.do_test_tag_assignments_with_filter_keywords(historic='parent')
-        self.do_test_tag_assignments_with_filter_keywords(bof=True)
-        self.do_test_tag_assignments_with_filter_keywords(bof=True, historic='group')
-        self.do_test_tag_assignments_with_filter_keywords(bof=True, historic='parent')
+        # use distinct meeting numbers > 111 for non-legacy keyword tests
+        self.do_test_tag_assignments_with_filter_keywords(112)
+        self.do_test_tag_assignments_with_filter_keywords(113, historic='group')
+        self.do_test_tag_assignments_with_filter_keywords(114, historic='parent')
+        self.do_test_tag_assignments_with_filter_keywords(115, bof=True)
+        self.do_test_tag_assignments_with_filter_keywords(116, bof=True, historic='group')
+        self.do_test_tag_assignments_with_filter_keywords(117, bof=True, historic='parent')
+
+
+    def test_tag_assignments_with_filter_keywords_legacy(self):
+        # use distinct meeting numbers <= 111 for legacy keyword tests
+        self.do_test_tag_assignments_with_filter_keywords(101)
+        self.do_test_tag_assignments_with_filter_keywords(102, historic='group')
+        self.do_test_tag_assignments_with_filter_keywords(103, historic='parent')
+        self.do_test_tag_assignments_with_filter_keywords(104, bof=True)
+        self.do_test_tag_assignments_with_filter_keywords(105, bof=True, historic='group')
+        self.do_test_tag_assignments_with_filter_keywords(106, bof=True, historic='parent')
+
+
+class AgendaFilterOrganizerTests(TestCase):
+    def test_get_filter_categories(self):
+        # set up
+        meeting = make_meeting_test_data()
+
+        # create extra groups for testing
+        iab = Group.objects.get(acronym='iab')
+        iab_child = GroupFactory(type_id='iab', parent=iab)
+        irtf = Group.objects.get(acronym='irtf')
+        irtf_child = GroupFactory(parent=irtf, state_id='bof')
+
+        # non-area group sessions
+        SessionFactory(group=iab_child, meeting=meeting)
+        SessionFactory(group=irtf_child, meeting=meeting)
+
+        # office hours session
+        SessionFactory(
+            group=Group.objects.get(acronym='farfut'),
+            name='FARFUT office hours',
+            meeting=meeting
+        )
+
+        expected = [
+            [
+                # area category
+                {'label': 'FARFUT', 'keyword': 'farfut', 'is_bof': False,
+                 'children': [
+                     {'label': 'ames', 'keyword': 'ames', 'is_bof': False},
+                     {'label': 'mars', 'keyword': 'mars', 'is_bof': False},
+                 ]},
+            ],
+            [
+                # non-area category
+                {'label': 'IAB', 'keyword': 'iab', 'is_bof': False,
+                 'children': [
+                     {'label': iab_child.acronym, 'keyword': iab_child.acronym, 'is_bof': False},
+                 ]},
+                {'label': 'IRTF', 'keyword': 'irtf', 'is_bof': False,
+                 'children': [
+                     {'label': irtf_child.acronym, 'keyword': irtf_child.acronym, 'is_bof': True},
+                 ]},
+            ],
+            [
+                # non-group category
+                {'label': 'Office Hours', 'keyword': 'officehours', 'is_bof': False,
+                 'children': [
+                     {'label': 'FARFUT', 'keyword': 'farfut-officehours', 'is_bof': False}
+                 ]},
+                {'label': None, 'keyword': None,'is_bof': False,
+                 'children': [
+                     {'label': 'BoF', 'keyword': 'bof', 'is_bof': False},
+                     {'label': 'Plenary', 'keyword': 'plenary', 'is_bof': False},
+                 ]},
+            ],
+        ]
+
+        # when using sessions instead of assignments, won't get timeslot-type based filters
+        expected_with_sessions = copy.deepcopy(expected)
+        expected_with_sessions[-1].pop(0)  # pops 'office hours' column
+
+        # put all the above together for single-column tests
+        expected_single_category = [
+            sorted(sum(expected, []), key=lambda col: col['label'] or 'zzzzz')
+        ]
+        expected_single_category_with_sessions = [
+            sorted(sum(expected_with_sessions, []), key=lambda col: col['label'] or 'zzzzz')
+        ]
+
+        ###
+        # test using sessions
+        sessions = meeting.session_set.all()
+        AgendaKeywordTagger(sessions=sessions).apply()
+
+        # default
+        filter_organizer = AgendaFilterOrganizer(sessions=sessions)
+        self.assertEqual(filter_organizer.get_filter_categories(), expected_with_sessions)
+
+        # single-column
+        filter_organizer = AgendaFilterOrganizer(sessions=sessions, single_category=True)
+        self.assertEqual(filter_organizer.get_filter_categories(), expected_single_category_with_sessions)
+
+        ###
+        # test again using assignments
+        assignments = meeting.schedule.assignments.all()
+        AgendaKeywordTagger(assignments=assignments).apply()
+
+        # default
+        filter_organizer = AgendaFilterOrganizer(assignments=assignments)
+        self.assertEqual(filter_organizer.get_filter_categories(), expected)
+
+        # single-column
+        filter_organizer = AgendaFilterOrganizer(assignments=assignments, single_category=True)
+        self.assertEqual(filter_organizer.get_filter_categories(), expected_single_category)
+
+    def test_get_non_area_keywords(self):
+        # set up
+        meeting = make_meeting_test_data()
+
+        # create a session in a 'special' group, which should then appear in the non-area keywords
+        team = GroupFactory(type_id='team')
+        SessionFactory(group=team, meeting=meeting)
+
+        # and a BoF
+        bof = GroupFactory(state_id='bof')
+        SessionFactory(group=bof, meeting=meeting)
+
+        expected = sorted(['bof', 'plenary', team.acronym.lower()])
+
+        ###
+        # by sessions
+        sessions = meeting.session_set.all()
+        AgendaKeywordTagger(sessions=sessions).apply()
+        filter_organizer = AgendaFilterOrganizer(sessions=sessions)
+        self.assertEqual(filter_organizer.get_non_area_keywords(), expected)
+
+        filter_organizer = AgendaFilterOrganizer(sessions=sessions, single_category=True)
+        self.assertEqual(filter_organizer.get_non_area_keywords(), expected)
+
+        ###
+        # by assignments
+        assignments = meeting.schedule.assignments.all()
+        AgendaKeywordTagger(assignments=assignments).apply()
+        filter_organizer = AgendaFilterOrganizer(assignments=assignments)
+        self.assertEqual(filter_organizer.get_non_area_keywords(), expected)
+
+        filter_organizer = AgendaFilterOrganizer(assignments=assignments, single_category=True)
+        self.assertEqual(filter_organizer.get_non_area_keywords(), expected)
\ No newline at end of file
diff --git a/ietf/meeting/tests_js.py b/ietf/meeting/tests_js.py
index 228fa21f7..2341221ca 100644
--- a/ietf/meeting/tests_js.py
+++ b/ietf/meeting/tests_js.py
@@ -1197,7 +1197,7 @@ class AgendaTests(IetfSeleniumTestCase):
         for item in self.get_expected_items():
             if item.session.name:
                 label = item.session.name
-            elif item.timeslot.type_id == 'break':
+            elif item.slot_type().slug == 'break':
                 label = item.timeslot.name
             elif item.session.group:
                 label = item.session.group.name
@@ -1308,6 +1308,7 @@ class AgendaTests(IetfSeleniumTestCase):
         self.assert_agenda_item_visibility([group_acronym])
         
         # Click the group button again
+        group_button = self.get_agenda_filter_group_button(wait, group_acronym)
         group_button.click()
 
         # Check visibility
@@ -1479,7 +1480,7 @@ class AgendaTests(IetfSeleniumTestCase):
         ics_url = self.absreverse('ietf.meeting.views.agenda_ical')
         
         # parse out the events
-        agenda_rows = self.driver.find_elements_by_css_selector('[id^="row-"]')
+        agenda_rows = self.driver.find_elements_by_css_selector('[id^="row-"]:not(.info)')
         visible_rows = [r for r in agenda_rows if r.is_displayed()]
         sessions = [self.session_from_agenda_row_id(row.get_attribute("id")) 
                     for row in visible_rows]
@@ -1736,6 +1737,16 @@ class AgendaTests(IetfSeleniumTestCase):
             self.fail('iframe href not updated to contain selected time zone')
 
     def test_agenda_session_selection(self):
+        # create a second mars session to test selection of specific sessions
+        SessionFactory(
+            meeting=self.meeting,
+            group__acronym='mars',
+            add_to_schedule=False,
+        ).timeslotassignments.create(
+            timeslot=TimeSlotFactory(meeting=self.meeting, duration=datetime.timedelta(minutes=60)),
+            schedule=self.meeting.schedule,
+        )
+
         wait = WebDriverWait(self.driver, 2)
         url = self.absreverse('ietf.meeting.views.agenda_personalize', kwargs={'num': self.meeting.number})
         self.driver.get(url)
@@ -1752,47 +1763,52 @@ class AgendaTests(IetfSeleniumTestCase):
             'Sessions were selected before being clicked',
         )
 
-        mars_checkbox = self.driver.find_element_by_css_selector('input[type="checkbox"][name="selected-sessions"][data-filter-item="mars"]')
+        mars_sessa_checkbox = self.driver.find_element_by_css_selector('input[type="checkbox"][name="selected-sessions"][data-filter-item="mars-sessa"]')
+        mars_sessb_checkbox = self.driver.find_element_by_css_selector('input[type="checkbox"][name="selected-sessions"][data-filter-item="mars-sessb"]')
+        farfut_button = self.driver.find_element_by_css_selector('button[data-filter-item="farfut"]')
         break_checkbox = self.driver.find_element_by_css_selector('input[type="checkbox"][name="selected-sessions"][data-filter-item="secretariat-sessb"]')
         registration_checkbox = self.driver.find_element_by_css_selector('input[type="checkbox"][name="selected-sessions"][data-filter-item="secretariat-sessa"]')
-        secretariat_button = self.driver.find_element_by_css_selector('button[data-filter-item="secretariat"]')
 
-        mars_checkbox.click()  # select mars session
+        mars_sessa_checkbox.click()  # select mars session
         try:
             wait.until(
-                lambda driver: all('?show=mars' in el.get_attribute('href') for el in elements_to_check)
+                lambda driver: all('?show=mars-sessa' in el.get_attribute('href') for el in elements_to_check)
             )
         except TimeoutException:
             self.fail('Some agenda links were not updated when mars session was selected')
-        self.assertTrue(mars_checkbox.is_selected(), 'mars session checkbox was not selected after being clicked')
+        self.assertTrue(mars_sessa_checkbox.is_selected(), 'mars session A checkbox was not selected after being clicked')
+        self.assertFalse(mars_sessb_checkbox.is_selected(), 'mars session B checkbox was selected without being clicked')
         self.assertFalse(break_checkbox.is_selected(), 'break checkbox was selected without being clicked')
         self.assertFalse(registration_checkbox.is_selected(), 'registration checkbox was selected without being clicked')
 
-        mars_checkbox.click()  # deselect mars session
+        mars_sessa_checkbox.click()  # deselect mars session
         try:
             wait.until(
-                lambda driver: not any('?show=mars' in el.get_attribute('href') for el in elements_to_check)
+                lambda driver: not any('?show=mars-sessa' in el.get_attribute('href') for el in elements_to_check)
             )
         except TimeoutException:
             self.fail('Some agenda links were not updated when mars session was de-selected')
-        self.assertFalse(mars_checkbox.is_selected(), 'mars session checkbox was still selected after being clicked')
+        self.assertFalse(mars_sessa_checkbox.is_selected(), 'mars session A checkbox was still selected after being clicked')
+        self.assertFalse(mars_sessb_checkbox.is_selected(), 'mars session B checkbox was selected without being clicked')
         self.assertFalse(break_checkbox.is_selected(), 'break checkbox was selected without being clicked')
         self.assertFalse(registration_checkbox.is_selected(), 'registration checkbox was selected without being clicked')
 
-        secretariat_button.click()  # turn on all secretariat sessions
+        farfut_button.click()  # turn on all farfut area sessions
+        mars_sessa_checkbox.click()  # but turn off mars session a
         break_checkbox.click()  # also select the break
 
         try:
             wait.until(
                 lambda driver: all(
-                    '?show=secretariat&hide=secretariat-sessb' in el.get_attribute('href')
+                    '?show=farfut,secretariat-sessb&hide=mars-sessa' in el.get_attribute('href')
                     for el in elements_to_check
                 ))
         except TimeoutException:
-            self.fail('Some agenda links were not updated when secretariat group but not break was selected')
-        self.assertFalse(mars_checkbox.is_selected(), 'mars session checkbox was unexpectedly selected')
-        self.assertFalse(break_checkbox.is_selected(), 'break checkbox was unexpectedly selected')
-        self.assertTrue(registration_checkbox.is_selected(), 'registration checkbox was expected to be selected')
+            self.fail('Some agenda links were not updated when farfut area was selected')
+        self.assertFalse(mars_sessa_checkbox.is_selected(), 'mars session A checkbox was unexpectedly selected')
+        self.assertTrue(mars_sessb_checkbox.is_selected(), 'mars session B checkbox was expected to be selected')
+        self.assertTrue(break_checkbox.is_selected(), 'break checkbox was expected to be selected')
+        self.assertFalse(registration_checkbox.is_selected(), 'registration checkbox was unexpectedly selected')
 
 @ifSeleniumEnabled
 class WeekviewTests(IetfSeleniumTestCase):
@@ -1814,7 +1830,7 @@ class WeekviewTests(IetfSeleniumTestCase):
         for item in self.get_expected_items():
             if item.session.name:
                 expected_name = item.session.name
-            elif item.timeslot.type_id == 'break':
+            elif item.slot_type().slug == 'break':
                 expected_name = item.timeslot.name
             else:
                 expected_name = item.session.group.name
@@ -1839,7 +1855,7 @@ class WeekviewTests(IetfSeleniumTestCase):
             for item in self.get_expected_items():
                 if item.session.name:
                     expected_name = item.session.name
-                elif item.timeslot.type_id == 'break':
+                elif item.slot_type().slug == 'break':
                     expected_name = item.timeslot.name
                 else:
                     expected_name = item.session.group.name
@@ -2580,6 +2596,165 @@ class ProceedingsMaterialTests(IetfSeleniumTestCase):
                         'URL field should be shown by default')
 
 
+class EditTimeslotsTests(IetfSeleniumTestCase):
+    """Test the timeslot editor"""
+    def setUp(self):
+        super().setUp()
+        self.meeting: Meeting = MeetingFactory(
+            type_id='ietf',
+            number=120,
+            date=datetime.datetime.today() + datetime.timedelta(days=10),
+            populate_schedule=False,
+        )
+        self.edit_timeslot_url = self.absreverse(
+            'ietf.meeting.views.edit_timeslots',
+            kwargs=dict(num=self.meeting.number),
+        )
+        self.wait = WebDriverWait(self.driver, 2)
+
+    def do_delete_test(self, selector, keep, delete, cancel=False):
+        self.login('secretary')
+        self.driver.get(self.edit_timeslot_url)
+        delete_button = self.wait.until(
+            expected_conditions.element_to_be_clickable(
+                (By.CSS_SELECTOR, selector)
+            ))
+        delete_button.click()
+
+        if cancel:
+            cancel_button = self.wait.until(
+                expected_conditions.element_to_be_clickable(
+                    (By.CSS_SELECTOR, '#delete-modal button[data-dismiss="modal"]')
+                ))
+            cancel_button.click()
+        else:
+            confirm_button = self.wait.until(
+                expected_conditions.element_to_be_clickable(
+                    (By.CSS_SELECTOR, '#confirm-delete-button')
+                ))
+            confirm_button.click()
+
+        self.wait.until(
+            expected_conditions.invisibility_of_element_located(
+                (By.CSS_SELECTOR, '#delete-modal')
+            ))
+
+        if cancel:
+            keep.extend(delete)
+            delete = []
+
+        self.assertEqual(
+            TimeSlot.objects.filter(pk__in=[ts.pk for ts in delete]).count(),
+            0,
+            'Not all expected timeslots deleted',
+        )
+        self.assertEqual(
+            TimeSlot.objects.filter(pk__in=[ts.pk for ts in keep]).count(),
+            len(keep),
+            'Not all expected timeslots kept'
+        )
+
+    def do_delete_timeslot_test(self, cancel=False):
+        delete = [TimeSlotFactory(meeting=self.meeting)]
+        keep = [TimeSlotFactory(meeting=self.meeting)]
+
+        self.do_delete_test(
+            '#timeslot-table #timeslot{} .delete-button'.format(delete[0].pk),
+            keep,
+            delete
+        )
+
+    def test_delete_timeslot(self):
+        """Delete button for a timeslot should delete that timeslot"""
+        self.do_delete_timeslot_test(cancel=False)
+
+    def test_delete_timeslot_cancel(self):
+        """Timeslot should not be deleted on cancel"""
+        self.do_delete_timeslot_test(cancel=True)
+
+    def do_delete_time_interval_test(self, cancel=False):
+        delete_day = self.meeting.date.date()
+        delete_time = datetime.time(hour=10)
+        other_day = self.meeting.get_meeting_date(1).date()
+        other_time = datetime.time(hour=12)
+        duration = datetime.timedelta(minutes=60)
+
+        delete: [TimeSlot] = TimeSlotFactory.create_batch(
+            2,
+            meeting=self.meeting,
+            time=datetime.datetime.combine(delete_day, delete_time),
+            duration=duration)
+
+        keep: [TimeSlot] = [
+            TimeSlotFactory(
+                meeting=self.meeting,
+                time=datetime.datetime.combine(day, time),
+                duration=duration
+            )
+            for (day, time) in (
+                # combinations of day/time that should not be deleted
+                (delete_day, other_time),
+                (other_day, delete_time),
+                (other_day, other_time),
+            )
+        ]
+
+        selector = (
+            '#timeslot-table '
+            '.delete-button[data-delete-scope="column"]'
+            '[data-col-id="{}T{}-{}"]'.format(
+                delete_day.isoformat(),
+                delete_time.strftime('%H:%M'),
+                (datetime.datetime.combine(delete_day, delete_time) + duration).strftime(
+                    '%H:%M'
+                ))
+        )
+        self.do_delete_test(selector, keep, delete, cancel)
+
+    def test_delete_time_interval(self):
+        """Delete button for a time interval should delete all timeslots in that interval"""
+        self.do_delete_time_interval_test(cancel=False)
+
+    def test_delete_time_interval_cancel(self):
+        """Should not delete a time interval on cancel"""
+        self.do_delete_time_interval_test(cancel=True)
+
+    def do_delete_day_test(self, cancel=False):
+        delete_day = self.meeting.date.date()
+        times = [datetime.time(hour=10), datetime.time(hour=12)]
+        other_days = [self.meeting.get_meeting_date(d).date() for d in range(1, 3)]
+
+        delete: [TimeSlot] = [
+            TimeSlotFactory(
+                meeting=self.meeting,
+                time=datetime.datetime.combine(delete_day, time),
+            ) for time in times
+        ]
+
+        keep: [TimeSlot] = [
+            TimeSlotFactory(
+                meeting=self.meeting,
+                time=datetime.datetime.combine(day, time),
+            ) for day in other_days for time in times
+        ]
+
+        selector = (
+            '#timeslot-table '
+            '.delete-button[data-delete-scope="day"][data-date-id="{}"]'.format(
+                delete_day.isoformat()
+            )
+        )
+        self.do_delete_test(selector, keep, delete, cancel)
+
+    def test_delete_day(self):
+        """Delete button for a day should delete all timeslots on that day"""
+        self.do_delete_day_test(cancel=False)
+
+    def test_delete_day_cancel(self):
+        """Should not delete a day on cancel"""
+        self.do_delete_day_test(cancel=True)
+
+
 # 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/tests_views.py b/ietf/meeting/tests_views.py
index eb7a61b1b..498b8298a 100644
--- a/ietf/meeting/tests_views.py
+++ b/ietf/meeting/tests_views.py
@@ -24,9 +24,10 @@ from django.urls import reverse as urlreverse
 from django.conf import settings
 from django.contrib.auth.models import User
 from django.test import Client, override_settings
-from django.db.models import F
+from django.db.models import F, Max
 from django.http import QueryDict, FileResponse
 from django.template import Context, Template
+from django.utils.text import slugify
 from django.utils.timezone import now
 
 import debug           # pyflakes:ignore
@@ -39,7 +40,6 @@ from ietf.meeting.helpers import can_approve_interim_request, can_view_interim_r
 from ietf.meeting.helpers import send_interim_approval_request
 from ietf.meeting.helpers import send_interim_meeting_cancellation_notice, send_interim_session_cancellation_notice
 from ietf.meeting.helpers import send_interim_minutes_reminder, populate_important_dates, update_important_dates
-from ietf.meeting.helpers import filter_keyword_for_specific_session
 from ietf.meeting.models import Session, TimeSlot, Meeting, SchedTimeSessAssignment, Schedule, SessionPresentation, SlideSubmission, SchedulingEvent, Room, Constraint, ConstraintName
 from ietf.meeting.test_data import make_meeting_test_data, make_interim_meeting, make_interim_test_data
 from ietf.meeting.utils import finalize, condition_slide_order
@@ -54,8 +54,8 @@ from ietf.utils.text import xslugify
 from ietf.person.factories import PersonFactory
 from ietf.group.factories import GroupFactory, GroupEventFactory, RoleFactory
 from ietf.meeting.factories import ( SessionFactory, ScheduleFactory,
-    SessionPresentationFactory, MeetingFactory, FloorPlanFactory, 
-    TimeSlotFactory, SlideSubmissionFactory, RoomFactory, 
+    SessionPresentationFactory, MeetingFactory, FloorPlanFactory,
+    TimeSlotFactory, SlideSubmissionFactory, RoomFactory,
     ConstraintFactory, MeetingHostFactory, ProceedingsMaterialFactory )
 from ietf.doc.factories import DocumentFactory, WgDraftFactory
 from ietf.submit.tests import submission_file
@@ -438,9 +438,12 @@ class MeetingTests(BaseMeetingTestCase):
             self.assertEqual(len(checkboxes), 1,
                              'Row for assignment {} does not have a checkbox input'.format(assignment))
             checkbox = checkboxes.eq(0)
+            kw_token = assignment.session.docname_token_only_for_multiple()
             self.assertEqual(
                 checkbox.attr('data-filter-item'),
-                filter_keyword_for_specific_session(assignment.session),
+                assignment.session.group.acronym.lower() + (
+                    '' if kw_token is None else f'-{kw_token}'
+                )
             )
 
     def test_agenda_personalize_updates_urls(self):
@@ -721,10 +724,13 @@ class MeetingTests(BaseMeetingTestCase):
             if g.parent_id is not None:
                 self.assertIn('%s?show=%s' % (ical_url, g.parent.acronym.lower()), content)
 
-        # Should be a 'non-area events' link showing appropriate types        
-        non_area_labels = [
-            'BOF', 'EDU', 'Hackathon', 'IEPG', 'IESG', 'IETF', 'Plenary', 'Secretariat', 'Tools',
-        ]
+        # The 'non-area events' are those whose keywords are in the last column of buttons
+        na_col = q('#customize td.view:last-child')  # find the column
+        non_area_labels = [e.attrib['data-filter-item']
+                           for e in na_col.find('button.pickview')]
+        assert len(non_area_labels) > 0  # test setup must produce at least one label for this test
+
+        # Should be a 'non-area events' link showing appropriate types
         self.assertIn('%s?show=%s' % (ical_url, ','.join(non_area_labels).lower()), content)
 
     def test_parse_agenda_filter_params(self):
@@ -1679,6 +1685,1048 @@ class EditMeetingScheduleTests(TestCase):
 
 
 
+class EditTimeslotsTests(TestCase):
+    def login(self, username='secretary'):
+        """Log in with permission to edit timeslots"""
+        self.client.login(username=username, password='{}+password'.format(username))
+
+    @staticmethod
+    def edit_timeslots_url(meeting):
+        return urlreverse('ietf.meeting.views.edit_timeslots', kwargs={'num': meeting.number})
+
+    @staticmethod
+    def edit_timeslot_url(ts: TimeSlot):
+        return urlreverse('ietf.meeting.views.edit_timeslot',
+                          kwargs={'num': ts.meeting.number, 'slot_id': ts.pk})
+
+    @staticmethod
+    def create_timeslots_url(meeting):
+        return urlreverse('ietf.meeting.views.create_timeslot', kwargs={'num': meeting.number})
+
+    @staticmethod
+    def create_bare_meeting(number=120) -> Meeting:
+        """Create a basic IETF meeting"""
+        return MeetingFactory(
+            type_id='ietf',
+            number=number,
+            date=datetime.datetime.today() + datetime.timedelta(days=10),
+            populate_schedule=False,
+        )
+
+    @staticmethod
+    def create_initial_schedule(meeting):
+        """Create initial / base schedule in the same manner as through the UI"""
+        owner = User.objects.get(username='secretary').person
+        base_schedule = Schedule.objects.create(
+            meeting=meeting,
+            name='base',
+            owner=owner,
+            visible=True,
+            public=True,
+        )
+
+        schedule = Schedule.objects.create(meeting = meeting,
+                                           name    = "%s-1" % slugify(owner.plain_name()),
+                                           owner   = owner,
+                                           visible = True,
+                                           public  = True,
+                                           base    = base_schedule,
+        )
+
+        meeting.schedule = schedule
+        meeting.save()
+
+    def create_meeting(self, number=120):
+        """Create a meeting ready for adding timeslots in the usual workflow"""
+        meeting = self.create_bare_meeting(number=number)
+        RoomFactory.create_batch(8, meeting=meeting)
+        self.create_initial_schedule(meeting)
+        return meeting
+
+    def test_view_permissions(self):
+        """Only the secretary should be able to edit timeslots"""
+        # test prep and helper method
+        usernames_to_reject = [
+            'plain',
+            RoleFactory(name_id='chair').person.user.username,
+            RoleFactory(name_id='ad', group__type_id='area').person.user.username,
+        ]
+        meeting = self.create_bare_meeting()
+        url = self.edit_timeslots_url(meeting)
+
+        def _assert_permissions(comment):
+            self.client.logout()
+            logged_in_username = '<nobody>'
+            try:
+                # loop through all the usernames that should be rejected
+                for username in usernames_to_reject:
+                    login_testing_unauthorized(self, username, url)
+                    logged_in_username = username
+                # test the last username to reject and log in as secretary
+                login_testing_unauthorized(self, 'secretary', url)
+            except AssertionError:
+                # give a better failure message
+                self.fail(
+                    '{} should not be able to access the edit timeslots page {}'.format(
+                        logged_in_username,
+                        comment,
+                    )
+                )
+            r = self.client.get(url)  # confirm secretary can retrieve the page
+            self.assertEqual(r.status_code, 200,
+                             'secretary should be able to access the edit timeslots page {}'.format(comment))
+
+        # Actual tests here
+        _assert_permissions('without schedule')  # first test without a meeting schedule
+        self.create_initial_schedule(meeting)
+        _assert_permissions('with schedule')  # then test with a meeting schedule
+
+    def test_linked_from_agenda_list(self):
+        """The edit timeslots view should be linked from the agenda list view"""
+        ad = RoleFactory(name_id='ad', group__type_id='area').person
+
+        meeting = self.create_bare_meeting()
+        self.create_initial_schedule(meeting)
+
+        url = urlreverse('ietf.meeting.views.list_schedules', kwargs={'num': meeting.number})
+
+        # Should have no link when logged in as area director
+        self.login(ad.user.username)
+        r = self.client.get(url)
+        self.assertEqual(r.status_code, 200)
+        q = PyQuery(r.content)
+        self.assertEqual(
+            len(q('a[href="{}"]'.format(self.edit_timeslots_url(meeting)))),
+            0,
+            'User who cannot edit timeslots should not see a link to the edit timeslots page'
+        )
+
+        # Should have a link when logged in as secretary
+        self.login()
+        r = self.client.get(url)
+        self.assertEqual(r.status_code, 200)
+        q = PyQuery(r.content)
+        self.assertGreaterEqual(
+            len(q('a[href="{}"]'.format(self.edit_timeslots_url(meeting)))),
+            1,
+            'Must be at least one link from the agenda list page to the edit timeslots page'
+        )
+
+    def assert_helpful_url(self, response, helpful_url, message):
+        q = PyQuery(response.content)
+        self.assertGreaterEqual(
+            len(q('.timeslot-edit a[href="{}"]'.format(helpful_url))),
+            1,
+            message,
+        )
+
+    def test_with_no_rooms(self):
+        """Editor should be helpful when there are no rooms yet"""
+        meeting = self.create_bare_meeting()
+        self.login()
+
+        # with no schedule, should get a link to the meeting page in the secr app until we can
+        # handle this situation in the meeting app
+        r = self.client.get(self.edit_timeslots_url(meeting))
+        self.assertEqual(r.status_code, 200)
+        self.assert_helpful_url(
+            r,
+            urlreverse('ietf.secr.meetings.views.view', kwargs={'meeting_id': meeting.number}),
+            'Must be a link to a helpful URL when there are no rooms and no schedule'
+        )
+
+        # with a schedule, should get a link to the create rooms page in the secr app
+        self.create_initial_schedule(meeting)
+        r = self.client.get(self.edit_timeslots_url(meeting))
+        self.assertEqual(r.status_code, 200)
+        self.assert_helpful_url(
+            r,
+            urlreverse('ietf.secr.meetings.views.rooms',
+                       kwargs={'meeting_id': meeting.number, 'schedule_name': meeting.schedule.name}),
+            'Must be a link to a helpful URL when there are no rooms'
+        )
+
+    def test_with_no_timeslots(self):
+        """Editor should be helpful when there are rooms but no timeslots yet"""
+        meeting = self.create_bare_meeting()
+        RoomFactory(meeting=meeting)
+        self.login()
+        helpful_url = self.create_timeslots_url(meeting)
+
+        # with no schedule, should get a link to the meeting page in the secr app until we can
+        # handle this situation in the meeting app
+        r = self.client.get(self.edit_timeslots_url(meeting))
+        self.assertEqual(r.status_code, 200)
+        self.assert_helpful_url(r, helpful_url,
+                                 'Must be a link to a helpful URL when there are no timeslots and no schedule')
+
+        # with a schedule, should get a link to the create rooms page in the secr app
+        self.create_initial_schedule(meeting)
+        r = self.client.get(self.edit_timeslots_url(meeting))
+        self.assertEqual(r.status_code, 200)
+        self.assert_helpful_url(r, helpful_url,
+                                 'Must be a link to a helpful URL when there are no timeslots')
+
+    def assert_required_links_present(self, response, meeting):
+        """Assert that required links on the editor page are present"""
+        q = PyQuery(response.content)
+        self.assertGreaterEqual(
+            len(q('a[href="{}"]'.format(self.create_timeslots_url(meeting)))),
+            1,
+            'Timeslot edit page should have a link to create timeslots'
+        )
+        self.assertGreaterEqual(
+            len(q('a[href="{}"]'.format(urlreverse('ietf.secr.meetings.views.rooms',
+                                                   kwargs={'meeting_id': meeting.number,
+                                                           'schedule_name': meeting.schedule.name}))
+                  )),
+            1,
+            'Timeslot edit page should have a link to edit rooms'
+        )
+
+    def test_required_links_present(self):
+        """Editor should have links to create timeslots and edit rooms"""
+        meeting = self.create_meeting()
+        self.create_initial_schedule(meeting)
+        RoomFactory.create_batch(8, meeting=meeting)
+
+        self.login()
+        r = self.client.get(self.edit_timeslots_url(meeting))
+        self.assertEqual(r.status_code, 200)
+        self.assert_required_links_present(r, meeting)
+
+    def test_shows_timeslots(self):
+        """Timeslots should be displayed properly"""
+        def _col_index(elt):
+            """Find the column index of an element in its table row
+
+            First column is 1
+            """
+            selector = 'td, th'  # accept both td and th elements
+            col_elt = elt.closest(selector)
+            tr = col_elt.parent('tr')
+            return 1 + tr.children(selector).index(col_elt[0])  # [0] gets bare element
+
+        meeting = self.create_meeting()
+        # add some timeslots
+        times = [datetime.time(hour=h) for h in (11, 14)]
+        days = [meeting.get_meeting_date(ii).date() for ii in range(meeting.days)]
+
+        timeslots = []
+        duration = datetime.timedelta(minutes=90)
+        for room in meeting.room_set.all():
+            for day in days:
+                timeslots.extend(
+                    TimeSlotFactory(
+                        meeting=meeting,
+                        location=room,
+                        time=datetime.datetime.combine(day, t),
+                        duration=duration,
+                    )
+                    for t in times
+                )
+
+        # get the page under test
+        self.login()
+        r = self.client.get(self.edit_timeslots_url(meeting))
+        self.assertEqual(r.status_code, 200)
+
+        q = PyQuery(r.content)
+        table = q('#timeslot-table')
+        self.assertEqual(len(table), 1, 'Exactly one timeslot-table required')
+        table = table.eq(0)
+
+        # check the day super-column headings
+        day_headings = table.find('.day-label')
+        self.assertEqual(len(day_headings), len(days))
+        day_columns = dict()  # map datetime to iterable with table col indices for that day
+        next_col = _col_index(day_headings.eq(0))  # find column of the first day
+        for day, heading in zip(days, day_headings.items()):
+            self.assertIn(day.strftime('%a'), heading.text(),
+                          'Weekday abbrev for {} not found in heading'.format(day))
+            self.assertIn(day.strftime('%Y-%m-%d'), heading.text(),
+                          'Numeric date for {} not found in heading'.format(day))
+            cols = int(heading.attr('colspan'))  # columns spanned by day header
+            day_columns[day] = range(next_col, next_col + cols)
+            next_col += cols
+
+        # check the timeslot time headings
+        time_headings = table.find('.time-label')
+        self.assertEqual(len(time_headings), len(times) * len(days))
+
+        expected_columns = dict()  # [date][time] element is expected column for a timeslot
+        for day, columns in day_columns.items():
+            headings = time_headings.filter(
+                # selector for children in any of the day's columns
+                ','.join(
+                    ':nth-child({})'.format(col)
+                    for col in columns
+                )
+            )
+            expected_columns[day] = dict()
+            for time, heading in zip(times, headings.items()):
+                self.assertIn(time.strftime('%H:%M'), heading.text(),
+                              'Timeslot start {} not found for day {}'.format(time, day))
+                expected_columns[day][time] = _col_index(heading)
+
+        # check that the expected timeslots are shown with expected info / ui features
+        timeslot_elts = table.find('.timeslot')
+        self.assertEqual(len(timeslot_elts), len(timeslots), 'Unexpected or missing timeslot elements')
+        for ts in timeslots:
+            pk_elts = timeslot_elts.filter('#timeslot{}'.format(ts.pk))
+            self.assertEqual(len(pk_elts), 1, 'Expect exactly one element for each timeslot')
+            elt = pk_elts.eq(0)
+            self.assertIn(ts.name, elt.text(), 'Timeslot name should appear in the element for {}'.format(ts))
+            self.assertIn(str(ts.type), elt.text(), 'Timeslot type should appear in the element for {}'.format(ts))
+            self.assertEqual(_col_index(elt), expected_columns[ts.time.date()][ts.time.time()],
+                             'Timeslot {} is in the wrong column'.format(ts))
+            delete_btn = elt.find('.delete-button[data-delete-scope="timeslot"]')
+            self.assertEqual(len(delete_btn), 1,
+                             'Timeslot {} should have one delete button'.format(ts))
+            edit_btn = elt.find('a[href="{}"]'.format(
+                urlreverse('ietf.meeting.views.edit_timeslot',
+                           kwargs=dict(num=meeting.number, slot_id=ts.pk))
+            ))
+            self.assertEqual(len(edit_btn), 1,
+                             'Timeslot {} should have one edit button'.format(ts))
+            # find the room heading for the row
+            tr = elt.closest('tr')
+            self.assertIn(ts.location.name, tr.children('th').eq(0).text(),
+                          'Timeslot {} is not shown in the correct row'.format(ts))
+
+    def test_bulk_delete_buttons_exist(self):
+        """Delete buttons for days and columns should be shown"""
+        meeting = self.create_meeting()
+        for day in range(meeting.days):
+            TimeSlotFactory(
+                meeting=meeting,
+                location=meeting.room_set.first(),
+                time=datetime.datetime.combine(
+                    meeting.get_meeting_date(day).date(),
+                    datetime.time(hour=11)
+                ),
+            )
+            TimeSlotFactory(
+                meeting=meeting,
+                location=meeting.room_set.first(),
+                time=datetime.datetime.combine(
+                    meeting.get_meeting_date(day).date(),
+                    datetime.time(hour=14)
+                ),
+            )
+
+        self.login()
+        r = self.client.get(self.edit_timeslots_url(meeting))
+        self.assertEqual(r.status_code, 200)
+
+        q = PyQuery(r.content)
+        table = q('#timeslot-table')
+        days = table.find('.day-label')
+        self.assertEqual(len(days), meeting.days, 'Wrong number of day labels')
+        for day_label in days.items():
+            self.assertEqual(len(day_label.find('.delete-button[data-delete-scope="day"]')), 1,
+                             'No delete button for day {}'.format(day_label.text()))
+
+        slots = table.find('.time-label')
+        self.assertEqual(len(slots), 2 * meeting.days, 'Wrong number of slot labels')
+        for slot_label in slots.items():
+            self.assertEqual(len(slot_label.find('.delete-button[data-delete-scope="column"]')), 1,
+                             'No delete button for slot {}'.format(slot_label.text()))
+
+    def test_timeslot_collision_flag(self):
+        """Overlapping timeslots in a room should be flagged
+
+        Only checks exact overlap because that is all we currently handle. The display puts
+        overlapping but not exactly matching timeslots in separate columns which must be
+        manually checked.
+        """
+        meeting = self.create_bare_meeting()
+
+        t1 = TimeSlotFactory(meeting=meeting)
+        TimeSlotFactory(meeting=meeting, time=t1.time, duration=t1.duration, location=t1.location)
+        TimeSlotFactory(meeting=meeting, time=t1.time, duration=t1.duration)  # other location
+        TimeSlotFactory(meeting=meeting, time=t1.time.replace(hour=t1.time.hour + 1), location=t1.location)  # other time
+
+        self.login()
+        r = self.client.get(self.edit_timeslots_url(meeting))
+        self.assertEqual(r.status_code, 200)
+
+        q = PyQuery(r.content)
+        slots = q('#timeslot-table .tscell')
+        self.assertEqual(len(slots), 4)  # one per location per distinct time
+        collision = slots.filter('.timeslot-collision')
+        no_collision = slots.filter(':not(.timeslot-collision)')
+        self.assertEqual(len(collision), 1, 'Wrong number of timeslot collisions flagged')
+        self.assertEqual(len(no_collision), 3, 'Wrong number of non-colliding timeslots')
+        # check that the cell containing t1 is the one flagged as a conflict
+        self.assertEqual(len(collision.find('#timeslot{}'.format(t1.pk))), 1,
+                         'Wrong timeslot cell flagged as having a collision')
+
+    def test_timeslot_in_use_flag(self):
+        """Timeslots that are in use should be flagged"""
+        meeting = self.create_meeting()
+
+        # assign sessions to some timeslots
+        empty, has_official, has_other = TimeSlotFactory.create_batch(3, meeting=meeting, location=meeting.room_set.first())
+        SchedTimeSessAssignment.objects.create(
+            timeslot=has_official,
+            session=SessionFactory(meeting=meeting, add_to_schedule=False),
+            schedule=meeting.schedule,  # official schedule
+        )
+
+        SchedTimeSessAssignment.objects.create(
+            timeslot=has_other,
+            session=SessionFactory(meeting=meeting, add_to_schedule=False),
+            schedule=ScheduleFactory(meeting=meeting),  # not the official schedule
+        )
+
+        # get the page
+        self.login()
+        r = self.client.get(self.edit_timeslots_url(meeting))
+        self.assertEqual(r.status_code, 200)
+
+        # now check that all timeslots appear, flagged appropriately
+        q = PyQuery(r.content)
+        empty_elt = q('#timeslot{}'.format(empty.pk))
+        has_official_elt = q('#timeslot{}'.format(has_official.pk))
+        has_other_elt = q('#timeslot{}'.format(has_other.pk))
+
+        self.assertEqual(empty_elt.attr('data-unofficial-use'), 'false', 'Unused timeslot should not be in use')
+        self.assertEqual(empty_elt.attr('data-official-use'), 'false', 'Unused timeslot should not be in use')
+
+        self.assertEqual(has_other_elt.attr('data-unofficial-use'), 'true',
+                         'Unofficially used timeslot should be flagged')
+        self.assertEqual(has_other_elt.attr('data-official-use'), 'false',
+                         'Unofficially used timeslot is not in official use')
+
+        self.assertEqual(has_official_elt.attr('data-unofficial-use'), 'false',
+                         'Officially used timeslot not in unofficial use')
+        self.assertEqual(has_official_elt.attr('data-official-use'), 'true',
+                         'Officially used timeslot should be flagged')
+
+    def test_edit_timeslot(self):
+        """Edit page should work as expected"""
+        meeting = self.create_meeting()
+
+        name_before = 'Name Classic (tm)'
+        type_before = 'regular'
+        time_before = datetime.datetime.combine(
+            meeting.date,
+            datetime.time(hour=10),
+        )
+        duration_before = datetime.timedelta(minutes=60)
+        show_location_before = True
+        location_before = meeting.room_set.first()
+        ts = TimeSlotFactory(
+            meeting=meeting,
+            name=name_before,
+            type_id=type_before,
+            time=time_before,
+            duration=duration_before,
+            show_location=show_location_before,
+            location=location_before,
+        )
+
+        self.login()
+        name_after = 'New Name (tm)'
+        type_after = 'plenary'
+        time_after = time_before.replace(day=time_before.day + 1, hour=time_before.hour + 2)
+        duration_after = duration_before * 2
+        show_location_after = False
+        location_after = meeting.room_set.last()
+        r = self.client.post(
+            self.edit_timeslot_url(ts),
+            data=dict(
+                name=name_after,
+                type=type_after,
+                time_0=time_after.strftime('%Y-%m-%d'),  # date for SplitDateTimeField
+                time_1=time_after.strftime('%H:%M'),  # time for SplitDateTimeField
+                duration=str(duration_after),
+                # show_location=show_location_after,  # False values are omitted from form
+                location=location_after.pk,
+            )
+        )
+        self.assertEqual(r.status_code, 302)  # expect redirect to timeslot edit url
+        self.assertEqual(r['Location'], self.edit_timeslots_url(meeting),
+                         'Expected to be redirected to meeting timeslots edit page')
+
+        # check that we changed things
+        self.assertNotEqual(name_before, name_after)
+        self.assertNotEqual(type_before, type_after)
+        self.assertNotEqual(time_before, time_after)
+        self.assertNotEqual(duration_before, duration_after)
+        self.assertNotEqual(location_before, location_after)
+
+        # and that we have the new values
+        ts = TimeSlot.objects.get(pk=ts.pk)
+        self.assertEqual(ts.name, name_after)
+        self.assertEqual(ts.type_id, type_after)
+        self.assertEqual(ts.time, time_after)
+        self.assertEqual(ts.duration, duration_after)
+        self.assertEqual(ts.show_location, show_location_after)
+        self.assertEqual(ts.location, location_after)
+
+    def test_invalid_edit_timeslot(self):
+        meeting = self.create_bare_meeting()
+        ts: TimeSlot = TimeSlotFactory(meeting=meeting, name='slot')  # n.b., colon indicates type hinting
+        self.login()
+        r = self.client.post(
+            self.edit_timeslot_url(ts),
+            data=dict(
+                name='',
+                type=ts.type.pk,
+                time_0=ts.time.strftime('%Y-%m-%d'),
+                time_1=ts.time.strftime('%H:%M'),
+                duration=str(ts.duration),
+                show_location=ts.show_location,
+                location=str(ts.location.pk),
+            )
+        )
+        self.assertContains(r, 'This field is required', status_code=400,
+                            msg_prefix='Missing name not properly rejected')
+
+        r = self.client.post(
+            self.edit_timeslot_url(ts),
+            data=dict(
+                name='different name',
+                type='this is not a type id',
+                time_0=ts.time.strftime('%Y-%m-%d'),
+                time_1=ts.time.strftime('%H:%M'),
+                duration=str(ts.duration),
+                show_location=ts.show_location,
+                location=str(ts.location.pk),
+            )
+        )
+        self.assertContains(r, 'Select a valid choice', status_code=400,
+                            msg_prefix='Invalid type not properly rejected')
+
+        r = self.client.post(
+            self.edit_timeslot_url(ts),
+            data=dict(
+                name='different name',
+                type=ts.type.pk,
+                time_0='this is not a date',
+                time_1=ts.time.strftime('%H:%M'),
+                duration=str(ts.duration),
+                show_location=ts.show_location,
+                location=str(ts.location.pk),
+            )
+        )
+        self.assertContains(r, 'Enter a valid date', status_code=400,
+                            msg_prefix='Invalid date not properly rejected')
+
+        r = self.client.post(
+            self.edit_timeslot_url(ts),
+            data=dict(
+                name='different name',
+                type=ts.type.pk,
+                time_0=ts.time.strftime('%Y-%m-%d'),
+                time_1='this is not a time',
+                duration=str(ts.duration),
+                show_location=ts.show_location,
+                location=str(ts.location.pk),
+            )
+        )
+        self.assertContains(r, 'Enter a valid time', status_code=400,
+                            msg_prefix='Invalid time not properly rejected')
+
+        r = self.client.post(
+            self.edit_timeslot_url(ts),
+            data=dict(
+                name='different name',
+                type=ts.type.pk,
+                time_0=ts.time.strftime('%Y-%m-%d'),
+                time_1=ts.time.strftime('%H:%M'),
+                duration='this is not a duration',
+                show_location=ts.show_location,
+                location=str(ts.location.pk),
+            )
+        )
+        self.assertContains(r, 'Enter a valid duration', status_code=400,
+                            msg_prefix='Invalid duration not properly rejected')
+
+        r = self.client.post(
+            self.edit_timeslot_url(ts),
+            data=dict(
+                name='different name',
+                type=ts.type.pk,
+                time_0=ts.time.strftime('%Y-%m-%d'),
+                time_1=ts.time.strftime('%H:%M'),
+                duration='26:00',  # longer than 12 hours,
+                show_location=ts.show_location,
+                location=str(ts.location.pk),
+            )
+        )
+        self.assertContains(r, 'Ensure this value is less than or equal to', status_code=400,
+                            msg_prefix='Overlong duration not properly rejected')
+
+        r = self.client.post(
+            self.edit_timeslot_url(ts),
+            data=dict(
+                name='different name',
+                type=str(ts.type.pk),
+                time_0=ts.time.strftime('%Y-%m-%d'),
+                time_1=ts.time.strftime('%H:%M'),
+                duration=str(ts.duration),
+                show_location=ts.show_location,
+                location='this is not a location',
+            )
+        )
+        self.assertContains(r, 'Select a valid choice', status_code=400,
+                            msg_prefix='Invalid location not properly rejected')
+
+        ts_after = meeting.timeslot_set.get(pk=ts.pk)
+        self.assertEqual(ts.name, ts_after.name)
+        self.assertEqual(ts.type, ts_after.type)
+        self.assertEqual(ts.time, ts_after.time)
+        self.assertEqual(ts.duration, ts_after.duration)
+        self.assertEqual(ts.show_location, ts_after.show_location)
+        self.assertEqual(ts.location, ts_after.location)
+
+    def test_create_single_timeslot(self):
+        """Creating a single timeslot should work"""
+        meeting = self.create_meeting()
+        timeslots_before = set(ts.pk for ts in meeting.timeslot_set.all())
+
+        post_data = dict(
+            name='some name',
+            type='regular',
+            days=str(meeting.date.toordinal()),
+            time='14:37',
+            duration='1:13',  # does not include seconds
+            show_location=True,
+            locations=str(meeting.room_set.first().pk),
+        )
+        self.login()
+        r = self.client.post(
+            self.create_timeslots_url(meeting),
+            data=post_data,
+        )
+        self.assertEqual(r.status_code, 302)
+        self.assertEqual(r['Location'], self.edit_timeslots_url(meeting),
+                         'Expected to be redirected to meeting timeslots edit page')
+
+        self.assertEqual(meeting.timeslot_set.count(), len(timeslots_before) + 1)
+        ts = meeting.timeslot_set.exclude(pk__in=timeslots_before).first()  # only 1
+        self.assertEqual(ts.name, post_data['name'])
+        self.assertEqual(ts.type_id, post_data['type'])
+        self.assertEqual(str(ts.time.date().toordinal()), post_data['days'])
+        self.assertEqual(ts.time.strftime('%H:%M'), post_data['time'])
+        self.assertEqual(str(ts.duration), '{}:00'.format(post_data['duration']))  # add seconds
+        self.assertEqual(ts.show_location, post_data['show_location'])
+        self.assertEqual(str(ts.location.pk), post_data['locations'])
+
+    def test_create_single_timeslot_outside_meeting_days(self):
+        """Creating a single timeslot outside the official meeting days should work"""
+        meeting = self.create_meeting()
+        timeslots_before = set(ts.pk for ts in meeting.timeslot_set.all())
+        other_date = meeting.get_meeting_date(-7).date()
+        post_data = dict(
+            name='some name',
+            type='regular',
+            other_date=other_date.strftime('%Y-%m-%d'),
+            time='14:37',
+            duration='1:13',  # does not include seconds
+            show_location=True,
+            locations=str(meeting.room_set.first().pk),
+        )
+        self.login()
+        r = self.client.post(
+            self.create_timeslots_url(meeting),
+            data=post_data,
+        )
+        self.assertEqual(r.status_code, 302)
+        self.assertEqual(r['Location'], self.edit_timeslots_url(meeting),
+                         'Expected to be redirected to meeting timeslots edit page')
+
+        self.assertEqual(meeting.timeslot_set.count(), len(timeslots_before) + 1)
+        ts = meeting.timeslot_set.exclude(pk__in=timeslots_before).first()  # only 1
+        self.assertEqual(ts.name, post_data['name'])
+        self.assertEqual(ts.type_id, post_data['type'])
+        self.assertEqual(ts.time.date(), other_date)
+        self.assertEqual(ts.time.strftime('%H:%M'), post_data['time'])
+        self.assertEqual(str(ts.duration), '{}:00'.format(post_data['duration']))  # add seconds
+        self.assertEqual(ts.show_location, post_data['show_location'])
+        self.assertEqual(str(ts.location.pk), post_data['locations'])
+
+
+    def test_invalid_create_timeslot(self):
+        meeting = self.create_bare_meeting()
+        room_pk = str(RoomFactory(meeting=meeting).pk)
+        timeslot_count = TimeSlot.objects.count()
+
+        self.login()
+        r = self.client.post(
+            self.create_timeslots_url(meeting),
+            data=dict(
+                name='',
+                type='regular',
+                days=str(meeting.date.toordinal()),
+                time='14:37',
+                duration='1:13',  # does not include seconds
+                show_location=True,
+                locations=room_pk,
+            )
+        )
+        self.assertContains(r, 'This field is required', status_code=400,
+                            msg_prefix='Empty name not rejected properly')
+
+        r = self.client.post(
+            self.create_timeslots_url(meeting),
+            data=dict(
+                name='this is a name',
+                type='this is not a type',
+                days=str(meeting.date.toordinal()),
+                time='14:37',
+                duration='1:13',  # does not include seconds
+                show_location=True,
+                locations=room_pk,
+            )
+        )
+        self.assertContains(r, 'Select a valid choice', status_code=400,
+                            msg_prefix='Invalid type not rejected properly')
+
+        r = self.client.post(
+            self.create_timeslots_url(meeting),
+            data=dict(
+                name='this is a name',
+                type='regular',
+                # days='',
+                time='14:37',
+                duration='1:13',  # does not include seconds
+                show_location=True,
+                locations=room_pk,
+            )
+        )
+        self.assertContains(r, 'Please select a day or specify a date', status_code=400,
+                            msg_prefix='Missing date not rejected properly')
+
+        r = self.client.post(
+            self.create_timeslots_url(meeting),
+            data=dict(
+                name='this is a name',
+                type='regular',
+                days='this is not an ordinal date',
+                time='14:37',
+                duration='1:13',  # does not include seconds
+                show_location=True,
+                locations=room_pk,
+            )
+        )
+        self.assertContains(r, 'Select a valid choice', status_code=400,
+                            msg_prefix='Invalid day not rejected properly')
+
+        r = self.client.post(
+            self.create_timeslots_url(meeting),
+            data=dict(
+                name='this is a name',
+                type='regular',
+                days=[str(meeting.date.toordinal()), 'this is not an ordinal date'],
+                time='14:37',
+                duration='1:13',  # does not include seconds
+                show_location=True,
+                locations=room_pk,
+            )
+        )
+        self.assertContains(r, 'Select a valid choice', status_code=400,
+                            msg_prefix='Invalid day with valid day not rejected properly')
+
+        r = self.client.post(
+            self.create_timeslots_url(meeting),
+            data=dict(
+                name='this is a name',
+                type='regular',
+                days=str(meeting.date.toordinal()),
+                other_date='this is not a date',
+                time='14:37',
+                duration='1:13',  # does not include seconds
+                show_location=True,
+                locations=room_pk,
+            )
+        )
+        self.assertContains(r, 'Enter a valid date', status_code=400,
+                            msg_prefix='Invalid other_date with valid days not rejected properly')
+
+        r = self.client.post(
+            self.create_timeslots_url(meeting),
+            data=dict(
+                name='this is a name',
+                type='regular',
+                days='this is not an ordinal date',
+                other_date='2021-07-13',
+                time='14:37',
+                duration='1:13',  # does not include seconds
+                show_location=True,
+                locations=room_pk,
+            )
+        )
+        self.assertContains(r, 'Select a valid choice', status_code=400,
+                            msg_prefix='Invalid day with valid other_date not rejected properly')
+
+        r = self.client.post(
+            self.create_timeslots_url(meeting),
+            data=dict(
+                name='this is a name',
+                type='regular',
+                other_date='this is not a date',
+                time='14:37',
+                duration='1:13',  # does not include seconds
+                show_location=True,
+                locations=room_pk,
+            )
+        )
+        self.assertContains(r, 'Enter a valid date', status_code=400,
+                            msg_prefix='Invalid other_date not rejected properly')
+        r = self.client.post(
+            self.create_timeslots_url(meeting),
+            data=dict(
+                name='this is a name',
+                type='regular',
+                days=str(meeting.date.toordinal()),
+                time='14:37',
+                duration="ceci n'est pas une duree",
+                show_location=True,
+                locations=room_pk,
+            )
+        )
+        self.assertContains(r, 'Enter a valid duration', status_code=400,
+                            msg_prefix='Invalid duration not rejected properly')
+
+        r = self.client.post(
+            self.create_timeslots_url(meeting),
+            data=dict(
+                name='this is a name',
+                type='regular',
+                days=str(meeting.date.toordinal()),
+                time='14:37',
+                duration="26:00",
+                show_location=True,
+                locations=room_pk,
+            )
+        )
+        self.assertContains(r, 'Ensure this value is less than or equal to', status_code=400,
+                            msg_prefix='Overlong duration not rejected properly')
+
+        r = self.client.post(
+            self.create_timeslots_url(meeting),
+            data=dict(
+                name='this is a name',
+                type='regular',
+                days=str(meeting.date.toordinal()),
+                time='14:37',
+                duration="1:13",
+                show_location=True,
+                locations='this is not a room',
+            )
+        )
+        self.assertContains(r, 'is not a valid value', status_code=400,
+                            msg_prefix='Invalid location not rejected properly')
+
+        r = self.client.post(
+            self.create_timeslots_url(meeting),
+            data=dict(
+                name='this is a name',
+                type='regular',
+                days=str(meeting.date.toordinal()),
+                time='14:37',
+                duration="1:13",
+                show_location=True,
+                locations=[room_pk, 'this is not a room'],
+            )
+        )
+        self.assertContains(r, 'is not a valid value', status_code=400,
+                            msg_prefix='Invalid location with valid location not rejected properly')
+
+        r = self.client.post(
+            self.create_timeslots_url(meeting),
+            data=dict(
+                name='this is a name',
+                type='regular',
+                days=str(meeting.date.toordinal()),
+                time='14:37',
+                duration="1:13",
+                show_location=True,
+            )
+        )
+        self.assertContains(r, 'This field is required', status_code=400,
+                            msg_prefix='Missing location not rejected properly')
+
+        self.assertEqual(TimeSlot.objects.count(), timeslot_count,
+                         'TimeSlot unexpectedly created')
+
+    def test_create_bulk_timeslots(self):
+        """Creating multiple timeslots should work"""
+        meeting = self.create_meeting()
+        timeslots_before = set(ts.pk for ts in meeting.timeslot_set.all())
+        days = [meeting.get_meeting_date(n).date() for n in range(meeting.days)]
+        other_date = meeting.get_meeting_date(-1).date()  # date before start of meeting
+        self.assertNotIn(other_date, days)
+        locations = meeting.room_set.all()
+        post_data = dict(
+            name='some name',
+            type='regular',
+            days=[str(d.toordinal()) for d in days],
+            other_date=other_date.strftime('%Y-%m-%d'),
+            time='14:37',
+            duration='1:13',  # does not include seconds
+            show_location=True,
+            locations=[str(loc.pk) for loc in locations],
+        )
+        self.login()
+        r = self.client.post(
+            self.create_timeslots_url(meeting),
+            data=post_data,
+        )
+        self.assertEqual(r.status_code, 302)
+        self.assertEqual(r['Location'], self.edit_timeslots_url(meeting),
+                         'Expected to be redirected to meeting timeslots edit page')
+
+        days.append(other_date)
+        new_slot_count = len(days) * len(locations)
+        self.assertEqual(meeting.timeslot_set.count(), len(timeslots_before) + new_slot_count)
+
+        day_locs = set((day, loc) for day in days for loc in locations)  # cartesian product
+        for ts in meeting.timeslot_set.exclude(pk__in=timeslots_before):
+            self.assertEqual(ts.name, post_data['name'])
+            self.assertEqual(ts.type_id, post_data['type'])
+            self.assertEqual(ts.time.strftime('%H:%M'), post_data['time'])
+            self.assertEqual(str(ts.duration), '{}:00'.format(post_data['duration']))  # add seconds
+            self.assertEqual(ts.show_location, post_data['show_location'])
+            self.assertIn(ts.time.date(), days)
+            self.assertIn(ts.location, locations)
+            self.assertIn((ts.time.date(), ts.location), day_locs,
+                          'Duplicated day / location found')
+            day_locs.discard((ts.time.date(), ts.location))
+        self.assertEqual(day_locs, set(), 'Not all day/location combinations created')
+
+    def test_ajax_delete_timeslot(self):
+        """AJAX call to delete timeslot should work"""
+        meeting = self.create_bare_meeting()
+        ts_to_del, ts_to_keep = TimeSlotFactory.create_batch(2, meeting=meeting)
+
+        self.login()
+        r = self.client.post(
+            self.edit_timeslots_url(meeting),
+            data=dict(
+                action='delete',
+                slot_id=str(ts_to_del.pk),
+            )
+        )
+        self.assertEqual(r.status_code, 200)
+        self.assertContains(r, 'Deleted TimeSlot {}'.format(ts_to_del.pk))
+        self.assertNotContains(r, 'Deleted TimeSlot {}'.format(ts_to_keep.pk))
+        self.assertEqual(meeting.timeslot_set.filter(pk=ts_to_del.pk).count(), 0,
+                         'Timeslot not deleted')
+        self.assertEqual(meeting.timeslot_set.filter(pk=ts_to_keep.pk).count(), 1,
+                         'Extra timeslot deleted')
+
+    def test_ajax_delete_timeslots(self):
+        """AJAX call to delete several timeslots should work"""
+        meeting = self.create_bare_meeting()
+        ts_to_del = TimeSlotFactory.create_batch(5, meeting=meeting)
+        ts_to_keep = TimeSlotFactory(meeting=meeting)
+
+        self.login()
+        r = self.client.post(
+            self.edit_timeslots_url(meeting),
+            data=dict(
+                action='delete',
+                slot_id=','.join(str(ts.pk) for ts in ts_to_del),
+            )
+        )
+        self.assertEqual(r.status_code, 200)
+        for ts in ts_to_del:
+            self.assertContains(r, 'Deleted TimeSlot {}'.format(ts.pk))
+        self.assertNotContains(r, 'Deleted TimeSlot {}'.format(ts_to_keep.pk))
+        self.assertEqual(
+            meeting.timeslot_set.filter(pk__in=(ts.pk for ts in ts_to_del)).count(),
+            0,
+            'Timeslots not deleted',
+        )
+        self.assertEqual(meeting.timeslot_set.filter(pk=ts_to_keep.pk).count(), 1,
+                         'Extra timeslot deleted')
+
+    def test_ajax_delete_timeslots_invalid(self):
+        meeting = self.create_bare_meeting()
+        ts = TimeSlotFactory(meeting=meeting)
+        self.login()
+        r = self.client.post(
+            self.edit_timeslots_url(meeting),
+        )
+        self.assertEqual(r.status_code, 400, 'Missing POST data not handled')
+
+        r = self.client.post(
+            self.edit_timeslots_url(meeting),
+            data=dict()
+        )
+        self.assertEqual(r.status_code, 400, 'Empty POST data not handled')
+
+        r = self.client.post(
+            self.edit_timeslots_url(meeting),
+            data=dict(
+                slot_id=str(ts.pk),
+            )
+        )
+        self.assertEqual(r.status_code, 400, 'Missing action not handled')
+
+        r = self.client.post(
+            self.edit_timeslots_url(meeting),
+            data=dict(
+                action='deletify',
+                slot_id=str(ts.pk),
+            )
+        )
+        self.assertEqual(r.status_code, 400, 'Invalid action not handled')
+
+        r = self.client.post(
+            self.edit_timeslots_url(meeting),
+            data=dict(
+                action='delete',
+            )
+        )
+        self.assertEqual(r.status_code, 400, 'Missing slot_id not handled')
+
+        r = self.client.post(
+            self.edit_timeslots_url(meeting),
+            data=dict(
+                action='delete',
+                slot_id='not an id',
+            )
+        )
+        self.assertEqual(r.status_code, 400, 'Invalid slot_id not handled')
+
+        r = self.client.post(
+            self.edit_timeslots_url(meeting),
+            data=dict(
+                action='delete',
+                slot_id='{}, not an id'.format(ts.pk),
+            )
+        )
+        self.assertEqual(r.status_code, 400, 'Invalid slot_id not handled in bulk')
+
+        nonexistent_id = TimeSlot.objects.all().aggregate(Max('id'))['id__max'] + 1
+        r = self.client.post(
+            self.edit_timeslots_url(meeting),
+            data=dict(
+                action='delete',
+                slot_id=str(nonexistent_id),
+            )
+        )
+        self.assertEqual(r.status_code, 404, 'Nonexistent slot_id not handled in bulk')
+
+        r = self.client.post(
+            self.edit_timeslots_url(meeting),
+            data=dict(
+                action='delete',
+                slot_id='{},{}'.format(nonexistent_id, ts.pk),
+            )
+        )
+        self.assertEqual(r.status_code, 404, 'Nonexistent slot_id not handled in bulk')
+
+        self.assertEqual(meeting.timeslot_set.filter(pk=ts.pk).count(), 1,
+                         'TimeSlot unexpectedly deleted')
+
+
 class ReorderSlidesTests(TestCase):
 
     def test_add_slides_to_session(self):
@@ -2024,7 +3072,7 @@ class EditTests(TestCase):
             meeting.date = datetime.date.today() + datetime.timedelta(days=days_offset)
             meeting.save()
             client.login(username="secretary", password="secretary+password")
-            url = urlreverse("ietf.meeting.views.edit_meeting_schedule", kwargs=dict(num=meeting.number)) 
+            url = urlreverse("ietf.meeting.views.edit_meeting_schedule", kwargs=dict(num=meeting.number))
             r = client.get(url)
             q = PyQuery(r.content)
             return(r, q)
@@ -2419,7 +3467,7 @@ class EditTests(TestCase):
             'time': assignment.timeslot.time.time().isoformat(),
             'duration': assignment.timeslot.duration,
             'location': assignment.timeslot.location_id,
-            'type': assignment.timeslot.type_id,
+            'type': assignment.slot_type().slug,
             'name': assignment.timeslot.name,
             'agenda_note': "New Test Note",
             'action': 'edit-timeslot',
diff --git a/ietf/meeting/urls.py b/ietf/meeting/urls.py
index 1bc18cac6..1fb1ebd50 100644
--- a/ietf/meeting/urls.py
+++ b/ietf/meeting/urls.py
@@ -53,6 +53,8 @@ type_ietf_only_patterns = [
     url(r'^agendas/diff/$', views.diff_schedules),
     url(r'^agenda/new/$', views.new_meeting_schedule),
     url(r'^timeslots/edit$',                     views.edit_timeslots),
+    url(r'^timeslot/new$',                       views.create_timeslot),
+    url(r'^timeslot/(?P<slot_id>\d+)/edit$',     views.edit_timeslot),
     url(r'^timeslot/(?P<slot_id>\d+)/edittype$', views.edit_timeslot_type),
     url(r'^rooms$',                              ajax.timeslot_roomsurl),
     url(r'^room/(?P<roomid>\d+).json$',          ajax.timeslot_roomurl),
diff --git a/ietf/meeting/utils.py b/ietf/meeting/utils.py
index 64427a5db..6ac8e4c10 100644
--- a/ietf/meeting/utils.py
+++ b/ietf/meeting/utils.py
@@ -529,6 +529,14 @@ def swap_meeting_schedule_timeslot_assignments(schedule, source_timeslots, targe
                 for a in lts_assignments:
                     a.delete()
 
+def bulk_create_timeslots(meeting, times, locations, other_props):
+    """Creates identical timeslots for Cartesian product of times and locations"""
+    for time in times:
+        for loc in locations:
+            properties = dict(time=time, location=loc)
+            properties.update(other_props)
+            meeting.timeslot_set.create(**properties)
+
 def preprocess_meeting_important_dates(meetings):
     for m in meetings:
         m.cached_updated = m.updated()
diff --git a/ietf/meeting/views.py b/ietf/meeting/views.py
index 4833b3216..5d06084cd 100644
--- a/ietf/meeting/views.py
+++ b/ietf/meeting/views.py
@@ -57,16 +57,17 @@ from ietf.ietfauth.utils import role_required, has_role, user_is_person
 from ietf.mailtrigger.utils import gather_address_lists
 from ietf.meeting.models import Meeting, Session, Schedule, FloorPlan, SessionPresentation, TimeSlot, SlideSubmission
 from ietf.meeting.models import SessionStatusName, SchedulingEvent, SchedTimeSessAssignment, Room, TimeSlotTypeName
-from ietf.meeting.forms import CustomDurationField, SwapDaysForm, SwapTimeslotsForm
+from ietf.meeting.forms import ( CustomDurationField, SwapDaysForm, SwapTimeslotsForm,
+                                 TimeSlotCreateForm, TimeSlotEditForm )
 from ietf.meeting.helpers import get_areas, get_person_by_email, get_schedule_by_name
 from ietf.meeting.helpers import build_all_agenda_slices, get_wg_name_list
 from ietf.meeting.helpers import get_all_assignments_from_schedule
 from ietf.meeting.helpers import get_modified_from_assignments
 from ietf.meeting.helpers import get_wg_list, find_ads_for_meeting
 from ietf.meeting.helpers import get_meeting, get_ietf_meeting, get_current_ietf_meeting_num
-from ietf.meeting.helpers import get_schedule, schedule_permissions, is_regular_agenda_filter_group
+from ietf.meeting.helpers import get_schedule, schedule_permissions
 from ietf.meeting.helpers import preprocess_assignments_for_agenda, read_agenda_file
-from ietf.meeting.helpers import filter_keywords_for_session, tag_assignments_with_filter_keywords, filter_keyword_for_specific_session
+from ietf.meeting.helpers import AgendaFilterOrganizer, AgendaKeywordTagger
 from ietf.meeting.helpers import convert_draft_to_pdf, get_earliest_session_date
 from ietf.meeting.helpers import can_view_interim_request, can_approve_interim_request
 from ietf.meeting.helpers import can_edit_interim_request
@@ -84,7 +85,7 @@ from ietf.meeting.utils import current_session_status
 from ietf.meeting.utils import data_for_meetings_overview
 from ietf.meeting.utils import preprocess_constraints_for_meeting_schedule_editor
 from ietf.meeting.utils import diff_meeting_schedules, prefetch_schedule_diff_objects
-from ietf.meeting.utils import swap_meeting_schedule_timeslot_assignments
+from ietf.meeting.utils import swap_meeting_schedule_timeslot_assignments, bulk_create_timeslots
 from ietf.meeting.utils import preprocess_meeting_important_dates
 from ietf.message.utils import infer_message
 from ietf.name.models import SlideSubmissionStatusName, ProceedingsMaterialTypeName
@@ -365,16 +366,49 @@ def edit_timeslots(request, num=None):
 
     meeting = get_meeting(num)
 
-    time_slices,date_slices,slots = meeting.build_timeslices()
+    if request.method == 'POST':
+        # handle AJAX requests
+        action = request.POST.get('action')
+        if action == 'delete':
+            # delete a timeslot
+            # Parameters:
+            #   slot_id: comma-separated list of TimeSlot PKs to delete
+            slot_id = request.POST.get('slot_id')
+            if slot_id is None:
+                return HttpResponseBadRequest('missing slot_id')
+            slot_ids = [id.strip() for id in slot_id.split(',')]
+            try:
+                timeslots = meeting.timeslot_set.filter(pk__in=slot_ids)
+            except ValueError:
+                return HttpResponseBadRequest('invalid slot_id specification')
+            missing_ids = set(slot_ids).difference(str(ts.pk) for ts in timeslots)
+            if len(missing_ids) != 0:
+                return HttpResponseNotFound('TimeSlot ids not found in meeting {}: {}'.format(
+                    meeting.number,
+                    ', '.join(sorted(missing_ids))
+                ))
+            timeslots.delete()
+            return HttpResponse(content='; '.join('Deleted TimeSlot {}'.format(id) for id in slot_ids))
+        else:
+            return HttpResponseBadRequest('unknown action')
+
+    # Labels here differ from those in the build_timeslices() method. The labels here are
+    # relative to the table: time_slices are the row headings (ie, days), date_slices are
+    # the column headings (i.e., time intervals), and slots are the per-day list of time slots
+    # (with only one time slot per unique time/duration)
+    time_slices, date_slices, slots = meeting.build_timeslices()
 
     ts_list = deque()
     rooms = meeting.room_set.order_by("capacity","name","id")
     for room in rooms:
         for day in time_slices:
             for slice in date_slices[day]:
-                ts_list.append(room.timeslot_set.filter(time=slice[0],duration=datetime.timedelta(seconds=slice[2])).first())
-            
+                ts_list.append(room.timeslot_set.filter(time=slice[0],duration=datetime.timedelta(seconds=slice[2])))
 
+    # Grab these in one query each to identify sessions that are in use and should be handled with care
+    ts_with_official_assignments = meeting.timeslot_set.filter(sessionassignments__schedule=meeting.schedule)
+    ts_with_any_assignments = meeting.timeslot_set.filter(sessionassignments__isnull=False)
+    
     return render(request, "meeting/timeslot_edit.html",
                                          {"rooms":rooms,
                                           "time_slices":time_slices,
@@ -382,6 +416,8 @@ def edit_timeslots(request, num=None):
                                           "date_slices":date_slices,
                                           "meeting":meeting,
                                           "ts_list":ts_list,
+                                          "ts_with_official_assignments": ts_with_official_assignments,
+                                          "ts_with_any_assignments": ts_with_any_assignments,
                                       })
 
 class NewScheduleForm(forms.ModelForm):
@@ -496,7 +532,7 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None):
     assignments = SchedTimeSessAssignment.objects.filter(
         schedule__in=[schedule, schedule.base],
         timeslot__location__isnull=False,
-        session__type='regular',
+        # session__type='regular',
     ).order_by('timeslot__time','timeslot__name')
 
     assignments_by_session = defaultdict(list)
@@ -508,7 +544,7 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None):
     sessions = add_event_info_to_session_qs(
         Session.objects.filter(
             meeting=meeting,
-            type='regular',
+            # type='regular',
         ).order_by('pk'),
         requested_time=True,
         requested_by=True,
@@ -519,7 +555,10 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None):
         'resources', 'group', 'group__parent', 'group__type', 'joint_with_groups',
     )
 
-    timeslots_qs = TimeSlot.objects.filter(meeting=meeting, type='regular').prefetch_related('type').order_by('location', 'time', 'name')
+    timeslots_qs = TimeSlot.objects.filter(
+        meeting=meeting,
+        # type='regular',
+    ).prefetch_related('type').order_by('location', 'time', 'name')
 
     min_duration = min(t.duration for t in timeslots_qs)
     max_duration = max(t.duration for t in timeslots_qs)
@@ -552,7 +591,7 @@ def edit_meeting_schedule(request, num=None, owner=None, name=None):
             s.requested_by_person = requested_by_lookup.get(s.requested_by)
 
             s.scheduling_label = "???"
-            if s.group:
+            if (s.purpose is None or s.purpose.slug == 'regular') and s.group:
                 s.scheduling_label = s.group.acronym
             elif s.name:
                 s.scheduling_label = s.name
@@ -1035,10 +1074,13 @@ class TimeSlotForm(forms.Form):
                 if not short:
                     self.add_error('short', 'When scheduling this type of time slot, a short name is required')
 
-            if self.timeslot and self.timeslot.type_id == 'regular' and self.active_assignment and ts_type.pk != self.timeslot.type_id:
+            if self.timeslot and self.timeslot.type.slug == 'regular' and self.active_assignment and ts_type.slug != self.timeslot.type.slug:
                 self.add_error('type', "Can't change type on time slots for regular sessions when a session has been assigned")
 
-        if self.active_assignment and self.active_assignment.session.group != self.cleaned_data.get('group') and self.active_assignment.session.materials.exists() and self.timeslot.type_id != 'regular':
+        if (self.active_assignment
+            and self.active_assignment.session.group != self.cleaned_data.get('group')
+            and self.active_assignment.session.materials.exists()
+            and self.timeslot.type.slug != 'regular'):
             self.add_error('group', "Can't change group after materials have been uploaded")
 
 
@@ -1450,6 +1492,7 @@ def list_schedules(request, num):
     return render(request, "meeting/schedule_list.html", {
         'meeting': meeting,
         'schedule_groups': schedule_groups,
+        'can_edit_timeslots': is_secretariat,
     })
 
 class DiffSchedulesForm(forms.Form):
@@ -1523,110 +1566,6 @@ def get_assignments_for_agenda(schedule):
     )
 
 
-def extract_groups_hierarchy(prepped_assignments):
-    """Extract groups hierarchy for agenda display
-
-    It's a little bit complicated because we can be dealing with historic groups.
-    """
-    seen = set()
-    groups = [a.session.historic_group for a in prepped_assignments
-              if a.session
-              and a.session.historic_group
-              and is_regular_agenda_filter_group(a.session.historic_group)
-              and a.session.historic_group.historic_parent]
-    group_parents = []
-    for g in groups:
-        if g.historic_parent.acronym not in seen:
-            group_parents.append(g.historic_parent)
-            seen.add(g.historic_parent.acronym)
-
-    seen = set()
-    for p in group_parents:
-        p.group_list = []
-        for g in groups:
-            if g.acronym not in seen and g.historic_parent.acronym == p.acronym:
-                p.group_list.append(g)
-                seen.add(g.acronym)
-
-        p.group_list.sort(key=lambda g: g.acronym)
-    return group_parents
-
-
-def prepare_filter_keywords(tagged_assignments, group_parents):
-    #
-    # The agenda_filter template expects a list of categorized header buttons, each
-    # with a list of children. Make two categories: the IETF areas and the other parent groups.
-    # We also pass a list of 'extra' buttons - currently Office Hours and miscellaneous filters.
-    # All but the last of these are additionally used by the agenda.html template to make
-    # a list of filtered ical buttons. The last group is ignored for this.
-    area_group_filters = []
-    other_group_filters = []
-    extra_filters = []
-
-    for p in group_parents:
-        new_filter = dict(
-            label=p.acronym.upper(),
-            keyword=p.acronym.lower(),
-            children=[
-                dict(
-                    label=g.acronym,
-                    keyword=g.acronym.lower(),
-                    is_bof=g.is_bof(),
-                ) for g in p.group_list
-            ]
-        )
-        if p.type.slug == 'area':
-            area_group_filters.append(new_filter)
-        else:
-            other_group_filters.append(new_filter)
-
-    office_hours_labels = set()
-    for a in tagged_assignments:
-        suffix = ' office hours'
-        if a.session.name.lower().endswith(suffix):
-            office_hours_labels.add(a.session.name[:-len(suffix)].strip())
-
-    if len(office_hours_labels) > 0:
-        # keyword needs to match what's tagged in filter_keywords_for_session()
-        extra_filters.append(dict(
-            label='Office Hours',
-            keyword='officehours',
-            children=[
-                dict(
-                    label=label,
-                    keyword=label.lower().replace(' ', '')+'officehours',
-                    is_bof=False,
-                ) for label in office_hours_labels
-            ]
-        ))
-
-    # Keywords that should appear in 'non-area' column
-    non_area_labels = [
-        'BOF', 'EDU', 'Hackathon', 'IEPG', 'IESG', 'IETF', 'Plenary', 'Secretariat', 'Tools',
-    ]
-    # Remove any unused non-area keywords
-    non_area_filters = [
-        dict(label=label, keyword=label.lower(), is_bof=False)
-        for label in non_area_labels if any([
-            label.lower() in assignment.filter_keywords
-            for assignment in tagged_assignments
-        ])
-    ]
-    if len(non_area_filters) > 0:
-        extra_filters.append(dict(
-            label=None,
-            keyword=None,
-            children=non_area_filters,
-        ))
-
-    area_group_filters.sort(key=lambda p:p['label'])
-    other_group_filters.sort(key=lambda p:p['label'])
-    filter_categories = [category
-                         for category in [area_group_filters, other_group_filters, extra_filters]
-                         if len(category) > 0]
-    return filter_categories, non_area_labels
-
-
 @ensure_csrf_cookie
 def agenda(request, num=None, name=None, base=None, ext=None, owner=None, utc=""):
     base = base if base else 'agenda'
@@ -1639,7 +1578,7 @@ def agenda(request, num=None, name=None, base=None, ext=None, owner=None, utc=""
 
     # We do not have the appropriate data in the datatracker for IETF 64 and earlier.
     # So that we're not producing misleading pages...
-    
+
     assert num is None or num.isdigit()
 
     meeting = get_ietf_meeting(num)
@@ -1667,17 +1606,13 @@ def agenda(request, num=None, name=None, base=None, ext=None, owner=None, utc=""
         get_assignments_for_agenda(schedule),
         meeting
     )
-    tag_assignments_with_filter_keywords(filtered_assignments)
+    AgendaKeywordTagger(assignments=filtered_assignments).apply()
 
     # Done processing for CSV output
     if ext == ".csv":
         return agenda_csv(schedule, filtered_assignments)
 
-    # Now prep the filter UI
-    filter_categories, non_area_labels = prepare_filter_keywords(
-        filtered_assignments,
-        extract_groups_hierarchy(filtered_assignments),
-    )
+    filter_organizer = AgendaFilterOrganizer(assignments=filtered_assignments)
 
     is_current_meeting = (num is None) or (num == get_current_ietf_meeting_num())
 
@@ -1685,8 +1620,8 @@ def agenda(request, num=None, name=None, base=None, ext=None, owner=None, utc=""
         "schedule": schedule,
         "filtered_assignments": filtered_assignments,
         "updated": updated,
-        "filter_categories": filter_categories,
-        "non_area_keywords": [label.lower() for label in non_area_labels],
+        "filter_categories": filter_organizer.get_filter_categories(),
+        "non_area_keywords": filter_organizer.get_non_area_keywords(),
         "now": datetime.datetime.now().astimezone(pytz.UTC),
         "timezone": meeting.time_zone,
         "is_current_meeting": is_current_meeting,
@@ -1728,23 +1663,23 @@ def agenda_csv(schedule, filtered_assignments):
         row.append(item.timeslot.time.strftime("%H%M"))
         row.append(item.timeslot.end_time().strftime("%H%M"))
 
-        if item.timeslot.type_id == "break":
-            row.append(item.timeslot.type.name)
+        if item.slot_type().slug == "break":
+            row.append(item.slot_type().name)
             row.append(schedule.meeting.break_area)
             row.append("")
             row.append("")
             row.append("")
             row.append(item.timeslot.name)
             row.append("b{}".format(item.timeslot.pk))
-        elif item.timeslot.type_id == "reg":
-            row.append(item.timeslot.type.name)
+        elif item.slot_type().slug == "reg":
+            row.append(item.slot_type().name)
             row.append(schedule.meeting.reg_area)
             row.append("")
             row.append("")
             row.append("")
             row.append(item.timeslot.name)
             row.append("r{}".format(item.timeslot.pk))
-        elif item.timeslot.type_id == "other":
+        elif item.slot_type().slug == "other":
             row.append("None")
             row.append(item.timeslot.location.name if item.timeslot.location else "")
             row.append("")
@@ -1752,7 +1687,7 @@ def agenda_csv(schedule, filtered_assignments):
             row.append(item.session.historic_group.historic_parent.acronym.upper() if item.session.historic_group.historic_parent else "")
             row.append(item.session.name)
             row.append(item.session.pk)
-        elif item.timeslot.type_id == "plenary":
+        elif item.slot_type().slug == "plenary":
             row.append(item.session.name)
             row.append(item.timeslot.location.name if item.timeslot.location else "")
             row.append("")
@@ -1762,7 +1697,7 @@ def agenda_csv(schedule, filtered_assignments):
             row.append(item.session.pk)
             row.append(agenda_field(item))
             row.append(slides_field(item))
-        elif item.timeslot.type_id == 'regular':
+        elif item.slot_type().slug == 'regular':
             row.append(item.timeslot.name)
             row.append(item.timeslot.location.name if item.timeslot.location else "")
             row.append(item.session.historic_group.historic_parent.acronym.upper() if item.session.historic_group.historic_parent else "")
@@ -1842,16 +1777,12 @@ def agenda_personalize(request, num):
         get_assignments_for_agenda(meeting.schedule),
         meeting
     )
-    tag_assignments_with_filter_keywords(filtered_assignments)
-    for assignment in filtered_assignments:
-        # may be None for some sessions
-        assignment.session_keyword = filter_keyword_for_specific_session(assignment.session)
+    tagger = AgendaKeywordTagger(assignments=filtered_assignments)
+    tagger.apply()  # annotate assignments with filter_keywords attribute
+    tagger.apply_session_keywords()  # annotate assignments with session_keyword attribute
 
     # Now prep the filter UI
-    filter_categories, non_area_labels = prepare_filter_keywords(
-        filtered_assignments,
-        extract_groups_hierarchy(filtered_assignments),
-    )
+    filter_organizer = AgendaFilterOrganizer(assignments=filtered_assignments)
 
     is_current_meeting = (num is None) or (num == get_current_ietf_meeting_num())
 
@@ -1862,8 +1793,8 @@ def agenda_personalize(request, num):
             'schedule': meeting.schedule,
             'updated': meeting.updated(),
             'filtered_assignments': filtered_assignments,
-            'filter_categories': filter_categories,
-            'non_area_labels': non_area_labels,
+            'filter_categories': filter_organizer.get_filter_categories(),
+            'non_area_labels': filter_organizer.get_non_area_keywords(),
             'timezone': meeting.time_zone,
             'is_current_meeting': is_current_meeting,
             'cache_time': 150 if is_current_meeting else 3600,
@@ -1989,7 +1920,7 @@ def week_view(request, num=None, name=None, owner=None):
         timeslot__type__private=False,
     )
     filtered_assignments = preprocess_assignments_for_agenda(filtered_assignments, meeting)
-    tag_assignments_with_filter_keywords(filtered_assignments)
+    AgendaKeywordTagger(assignments=filtered_assignments).apply()
 
     items = []
     for a in filtered_assignments:
@@ -1998,7 +1929,7 @@ def week_view(request, num=None, name=None, owner=None):
             "key": str(a.timeslot.pk),
             "utc_time": a.timeslot.utc_start_time().strftime("%Y%m%dT%H%MZ"),  # ISO8601 compliant
             "duration": a.timeslot.duration.seconds,
-            "type": a.timeslot.type.name,
+            "type": a.slot_type().name,
             "filter_keywords": ",".join(a.filter_keywords),
         }
 
@@ -2008,10 +1939,10 @@ def week_view(request, num=None, name=None, owner=None):
 
             if a.session.name:
                 item["name"] = a.session.name
-            elif a.timeslot.type_id == "break":
+            elif a.slot_type().slug == "break":
                 item["name"] = a.timeslot.name
-                item["area"] = a.timeslot.type_id
-                item["group"] = a.timeslot.type_id
+                item["area"] = a.slot_type().slug
+                item["group"] = a.slot_type().slug
             elif a.session.historic_group:
                 item["name"] = a.session.historic_group.name
                 if a.session.historic_group.state_id == "bof":
@@ -2172,7 +2103,7 @@ def agenda_ical(request, num=None, name=None, acronym=None, session_id=None):
         timeslot__type__private=False,
     )
     assignments = preprocess_assignments_for_agenda(assignments, meeting)
-    tag_assignments_with_filter_keywords(assignments)
+    AgendaKeywordTagger(assignments=assignments).apply()
 
     try:
         filt_params = parse_agenda_filter_params(request.GET)
@@ -2333,7 +2264,7 @@ def meeting_requests(request, num=None):
     sessions = add_event_info_to_session_qs(
         Session.objects.filter(
             meeting__number=meeting.number,
-            type__slug='regular',
+            # type__slug='regular',
             group__parent__isnull=False
         ),
         requested_by=True,
@@ -3625,37 +3556,12 @@ def upcoming(request):
         )
     ).filter(current_status__in=('sched','canceled'))
 
-    # get groups for group UI display - same algorithm as in agenda(), but
-    # using group / parent instead of historic_group / historic_parent
-    groups = [s.group for s in interim_sessions
-              if s.group
-              and is_regular_agenda_filter_group(s.group)
-              and s.group.parent]
-    group_parents = {g.parent for g in groups if g.parent}
-    seen = set()
-    for p in group_parents:
-        p.group_list = []
-        for g in groups:
-            if g.acronym not in seen and g.parent.acronym == p.acronym:
-                p.group_list.append(g)
-                seen.add(g.acronym)
-
-    # only one category
-    filter_categories = [[
-        dict(
-            label=p.acronym, 
-            keyword=p.acronym.lower(),
-            children=[dict(
-                label=g.acronym,
-                keyword=g.acronym.lower(),
-                is_bof=g.is_bof(),
-            ) for g in p.group_list]
-        ) for p in group_parents
-    ]]
-    
     for session in interim_sessions:
         session.historic_group = session.group
-        session.filter_keywords = filter_keywords_for_session(session)
+
+    # Set up for agenda filtering - only one filter_category here
+    AgendaKeywordTagger(sessions=interim_sessions).apply()
+    filter_organizer = AgendaFilterOrganizer(sessions=interim_sessions, single_category=True)
 
     entries = list(ietf_meetings)
     entries.extend(list(interim_sessions))
@@ -3694,7 +3600,7 @@ def upcoming(request):
 
     return render(request, 'meeting/upcoming.html', {
                   'entries': entries,
-                  'filter_categories': filter_categories,
+                  'filter_categories': filter_organizer.get_filter_categories(),
                   'menu_actions': actions,
                   'menu_entries': menu_entries,
                   'selected_menu_entry': selected_menu_entry,
@@ -3728,7 +3634,7 @@ def upcoming_ical(request):
         'session__group', 'session__group__parent', 'timeslot', 'schedule', 'schedule__meeting'
     ).distinct())
 
-    tag_assignments_with_filter_keywords(assignments)
+    AgendaKeywordTagger(assignments=assignments).apply()
 
     # apply filters
     if filter_params is not None:
@@ -3820,7 +3726,7 @@ def proceedings(request, num=None):
     plenaries = sessions.filter(name__icontains='plenary').exclude(current_status='notmeet')
     ietf      = sessions.filter(group__parent__type__slug = 'area').exclude(group__acronym='edu')
     irtf      = sessions.filter(group__parent__acronym = 'irtf')
-    training  = sessions.filter(group__acronym__in=['edu','iaoc'], type_id__in=['regular', 'other', ]).exclude(current_status='notmeet')
+    training  = sessions.filter(group__acronym__in=['edu','iaoc'], type_id__in=['regular', 'other',]).exclude(current_status='notmeet')
     iab       = sessions.filter(group__parent__acronym = 'iab').exclude(current_status='notmeet')
 
     cache_version = Document.objects.filter(session__meeting__number=meeting.number).aggregate(Max('time'))["time__max"]
@@ -4115,11 +4021,65 @@ def edit_timeslot_type(request, num, slot_id):
 
     else:
         form = TimeSlotTypeForm(instance=timeslot)
-        
+
     sessions = timeslot.sessions.filter(timeslotassignments__schedule__in=[meeting.schedule, meeting.schedule.base if meeting.schedule else None])
 
     return render(request, 'meeting/edit_timeslot_type.html', {'timeslot':timeslot,'form':form,'sessions':sessions})
 
+@role_required('Secretariat')
+def edit_timeslot(request, num, slot_id):
+    timeslot = get_object_or_404(TimeSlot, id=slot_id)
+    meeting = get_object_or_404(Meeting, number=num)
+    if timeslot.meeting != meeting:
+        raise Http404()
+    if request.method == 'POST':
+        form = TimeSlotEditForm(instance=timeslot, data=request.POST)
+        if form.is_valid():
+            form.save()
+            return HttpResponseRedirect(reverse('ietf.meeting.views.edit_timeslots', kwargs={'num': num}))
+    else:
+        form = TimeSlotEditForm(instance=timeslot)
+
+    sessions = timeslot.sessions.filter(
+        timeslotassignments__schedule__in=[meeting.schedule, meeting.schedule.base if meeting.schedule else None])
+
+    return render(
+        request,
+        'meeting/edit_timeslot.html',
+        {'timeslot': timeslot, 'form': form, 'sessions': sessions},
+        status=400 if form.errors else 200,
+    )
+
+
+@role_required('Secretariat')
+def create_timeslot(request, num):
+    meeting = get_object_or_404(Meeting, number=num)
+    if request.method == 'POST':
+        form = TimeSlotCreateForm(meeting, data=request.POST)
+        if form.is_valid():
+            bulk_create_timeslots(
+                meeting,
+                [datetime.datetime.combine(day, form.cleaned_data['time'])
+                 for day in form.cleaned_data.get('days', [])],
+                form.cleaned_data['locations'],
+                dict(
+                    name=form.cleaned_data['name'],
+                    type=form.cleaned_data['type'],
+                    duration=form.cleaned_data['duration'],
+                    show_location=form.cleaned_data['show_location'],
+                )
+            )
+            return HttpResponseRedirect(reverse('ietf.meeting.views.edit_timeslots',kwargs={'num':num}))
+    else:
+        form = TimeSlotCreateForm(meeting)
+
+    return render(
+        request,
+        'meeting/create_timeslot.html',
+        dict(meeting=meeting, form=form),
+        status=400 if form.errors else 200,
+    )
+
 
 @role_required('Secretariat')
 def request_minutes(request, num=None):
diff --git a/ietf/name/admin.py b/ietf/name/admin.py
index d14778082..0cd6265e8 100644
--- a/ietf/name/admin.py
+++ b/ietf/name/admin.py
@@ -11,7 +11,8 @@ from ietf.name.models import (
     ReviewRequestStateName, ReviewResultName, ReviewTypeName, RoleName, RoomResourceName,
     SessionStatusName, StdLevelName, StreamName, TimeSlotTypeName, TopicAudienceName,
     DocUrlTagName, ReviewAssignmentStateName, ReviewerQueuePolicyName, TimerangeName,
-    ExtResourceName, ExtResourceTypeName, SlideSubmissionStatusName, ProceedingsMaterialTypeName)
+    ExtResourceName, ExtResourceTypeName, SlideSubmissionStatusName, ProceedingsMaterialTypeName,
+    AgendaFilterTypeName, SessionPurposeName )
 
 
 from ietf.stats.models import CountryAlias
@@ -56,6 +57,7 @@ class ProceedingsMaterialTypeNameAdmin(NameAdmin):
     list_display = ["slug", "name", "desc", "used", "order",]
 admin.site.register(ProceedingsMaterialTypeName, ProceedingsMaterialTypeNameAdmin)
 
+admin.site.register(AgendaFilterTypeName, NameAdmin)
 admin.site.register(AgendaTypeName, NameAdmin)
 admin.site.register(BallotPositionName, NameAdmin)
 admin.site.register(ConstraintName, NameAdmin)
@@ -94,3 +96,4 @@ admin.site.register(TopicAudienceName, NameAdmin)
 admin.site.register(DocUrlTagName, NameAdmin)
 admin.site.register(ExtResourceTypeName, NameAdmin)
 admin.site.register(SlideSubmissionStatusName, NameAdmin)
+admin.site.register(SessionPurposeName, NameAdmin)
\ No newline at end of file
diff --git a/ietf/name/fixtures/names.json b/ietf/name/fixtures/names.json
index 0b5d42f3c..213a3efab 100644
--- a/ietf/name/fixtures/names.json
+++ b/ietf/name/fixtures/names.json
@@ -2592,6 +2592,7 @@
       "about_page": "ietf.group.views.group_about",
       "acts_like_wg": false,
       "admin_roles": "[\n    \"chair\"\n]",
+      "agenda_filter_type": "special",
       "agenda_type": "ietf",
       "create_wiki": true,
       "custom_group_roles": false,
@@ -2629,6 +2630,7 @@
       "about_page": "ietf.group.views.group_about",
       "acts_like_wg": false,
       "admin_roles": "[\n    \"chair\"\n]",
+      "agenda_filter_type": "none",
       "agenda_type": "ietf",
       "create_wiki": false,
       "custom_group_roles": false,
@@ -2664,6 +2666,7 @@
       "about_page": "ietf.group.views.group_about",
       "acts_like_wg": true,
       "admin_roles": "[\n    \"chair\"\n]",
+      "agenda_filter_type": "normal",
       "agenda_type": "ietf",
       "create_wiki": true,
       "custom_group_roles": true,
@@ -2702,6 +2705,7 @@
       "about_page": "ietf.group.views.group_about",
       "acts_like_wg": false,
       "admin_roles": "[\n    \"ad\"\n]",
+      "agenda_filter_type": "heading",
       "agenda_type": "ietf",
       "create_wiki": true,
       "custom_group_roles": true,
@@ -2739,6 +2743,7 @@
       "about_page": "ietf.group.views.group_about",
       "acts_like_wg": false,
       "admin_roles": "[\n    \"chair\",\n    \"secr\"\n]",
+      "agenda_filter_type": "none",
       "agenda_type": "ad",
       "create_wiki": true,
       "custom_group_roles": true,
@@ -2776,6 +2781,7 @@
       "about_page": "ietf.group.views.group_about",
       "acts_like_wg": false,
       "admin_roles": "[\n    \"chair\"\n]",
+      "agenda_filter_type": "normal",
       "agenda_type": "ietf",
       "create_wiki": false,
       "custom_group_roles": true,
@@ -2850,6 +2856,7 @@
       "about_page": "ietf.group.views.group_about",
       "acts_like_wg": false,
       "admin_roles": "[\n    \"chair\"\n]",
+      "agenda_filter_type": "none",
       "agenda_type": "ietf",
       "create_wiki": false,
       "custom_group_roles": false,
@@ -2885,6 +2892,7 @@
       "about_page": "ietf.group.views.group_about",
       "acts_like_wg": false,
       "admin_roles": "[\n    \"chair\"\n]",
+      "agenda_filter_type": "none",
       "agenda_type": "ad",
       "create_wiki": false,
       "custom_group_roles": true,
@@ -2920,6 +2928,7 @@
       "about_page": "ietf.group.views.group_about",
       "acts_like_wg": false,
       "admin_roles": "[\n    \"chair\",\n    \"lead\"\n]",
+      "agenda_filter_type": "heading",
       "agenda_type": "ietf",
       "create_wiki": false,
       "custom_group_roles": true,
@@ -2957,6 +2966,7 @@
       "about_page": "ietf.group.views.group_about",
       "acts_like_wg": false,
       "admin_roles": "[\n    \"chair\"\n]",
+      "agenda_filter_type": "none",
       "agenda_type": null,
       "create_wiki": false,
       "custom_group_roles": true,
@@ -2994,6 +3004,7 @@
       "about_page": "ietf.group.views.group_about",
       "acts_like_wg": false,
       "admin_roles": "[\n    \"chair\"\n]",
+      "agenda_filter_type": "heading",
       "agenda_type": "ietf",
       "create_wiki": false,
       "custom_group_roles": true,
@@ -3031,6 +3042,7 @@
       "about_page": "ietf.group.views.group_about",
       "acts_like_wg": false,
       "admin_roles": "[\n    \"chair\",\n    \"lead\"\n]",
+      "agenda_filter_type": "none",
       "agenda_type": "ad",
       "create_wiki": false,
       "custom_group_roles": true,
@@ -3066,6 +3078,7 @@
       "about_page": "ietf.group.views.group_about",
       "acts_like_wg": false,
       "admin_roles": "[\n    \"chair\"\n]",
+      "agenda_filter_type": "none",
       "agenda_type": null,
       "create_wiki": false,
       "custom_group_roles": true,
@@ -3103,6 +3116,7 @@
       "about_page": "ietf.group.views.group_about",
       "acts_like_wg": false,
       "admin_roles": "[\n    \"chair\",\n    \"advisor\"\n]",
+      "agenda_filter_type": "none",
       "agenda_type": "side",
       "create_wiki": true,
       "custom_group_roles": true,
@@ -3140,6 +3154,7 @@
       "about_page": "ietf.group.views.group_about",
       "acts_like_wg": false,
       "admin_roles": "[\n    \"lead\"\n]",
+      "agenda_filter_type": "normal",
       "agenda_type": "ad",
       "create_wiki": false,
       "custom_group_roles": true,
@@ -3177,6 +3192,7 @@
       "about_page": "ietf.group.views.group_about",
       "acts_like_wg": true,
       "admin_roles": "[\n    \"chair\"\n]",
+      "agenda_filter_type": "normal",
       "agenda_type": "ietf",
       "create_wiki": true,
       "custom_group_roles": true,
@@ -3214,6 +3230,7 @@
       "about_page": "ietf.group.views.group_about",
       "acts_like_wg": false,
       "admin_roles": "[\n    \"chair\",\n    \"secr\"\n]",
+      "agenda_filter_type": "normal",
       "agenda_type": "ietf",
       "create_wiki": true,
       "custom_group_roles": true,
@@ -3251,6 +3268,7 @@
       "about_page": "ietf.group.views.group_about",
       "acts_like_wg": false,
       "admin_roles": "[\n    \"chair\"\n]",
+      "agenda_filter_type": "none",
       "agenda_type": "side",
       "create_wiki": false,
       "custom_group_roles": true,
@@ -3286,6 +3304,7 @@
       "about_page": "ietf.group.views.group_about",
       "acts_like_wg": true,
       "admin_roles": "[\n    \"chair\"\n]",
+      "agenda_filter_type": "normal",
       "agenda_type": "ietf",
       "create_wiki": true,
       "custom_group_roles": false,
@@ -3323,6 +3342,7 @@
       "about_page": "ietf.group.views.group_about",
       "acts_like_wg": false,
       "admin_roles": "[\n    \"chair\"\n]",
+      "agenda_filter_type": "none",
       "agenda_type": null,
       "create_wiki": false,
       "custom_group_roles": true,
@@ -3361,6 +3381,7 @@
       "about_page": "ietf.group.views.group_about",
       "acts_like_wg": false,
       "admin_roles": "[\n    \"chair\"\n]",
+      "agenda_filter_type": "special",
       "agenda_type": "ietf",
       "create_wiki": true,
       "custom_group_roles": true,
@@ -3398,6 +3419,7 @@
       "about_page": "ietf.group.views.group_about",
       "acts_like_wg": true,
       "admin_roles": "[\n    \"chair\"\n]",
+      "agenda_filter_type": "normal",
       "agenda_type": "ietf",
       "create_wiki": true,
       "custom_group_roles": false,
@@ -6095,6 +6117,46 @@
     "model": "meeting.businessconstraint",
     "pk": "sessions_out_of_order"
   },
+  {
+    "fields": {
+      "desc": "Column heading button",
+      "name": "Heading",
+      "order": 2,
+      "used": true
+    },
+    "model": "name.agendafiltertypename",
+    "pk": "heading"
+  },
+  {
+    "fields": {
+      "desc": "Not used except for a timeslot-type column (e.g., officehours)",
+      "name": "None",
+      "order": 0,
+      "used": true
+    },
+    "model": "name.agendafiltertypename",
+    "pk": "none"
+  },
+  {
+    "fields": {
+      "desc": "Non-heading filter button",
+      "name": "Normal",
+      "order": 1,
+      "used": true
+    },
+    "model": "name.agendafiltertypename",
+    "pk": "normal"
+  },
+  {
+    "fields": {
+      "desc": "Button in the catch-all column",
+      "name": "Special",
+      "order": 3,
+      "used": true
+    },
+    "model": "name.agendafiltertypename",
+    "pk": "special"
+  },
   {
     "fields": {
       "desc": "",
@@ -13129,6 +13191,17 @@
     "model": "name.timeslottypename",
     "pk": "offagenda"
   },
+  {
+    "fields": {
+      "desc": "Office hours timeslot",
+      "name": "Office Hours",
+      "order": 0,
+      "private": false,
+      "used": true
+    },
+    "model": "name.timeslottypename",
+    "pk": "officehours"
+  },
   {
     "fields": {
       "desc": "",
diff --git a/ietf/name/migrations/0032_agendafiltertypename.py b/ietf/name/migrations/0032_agendafiltertypename.py
new file mode 100644
index 000000000..6c6fa4eab
--- /dev/null
+++ b/ietf/name/migrations/0032_agendafiltertypename.py
@@ -0,0 +1,27 @@
+# Generated by Django 2.2.19 on 2021-04-02 12:03
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('name', '0031_add_procmaterials'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='AgendaFilterTypeName',
+            fields=[
+                ('slug', models.CharField(max_length=32, primary_key=True, serialize=False)),
+                ('name', models.CharField(max_length=255)),
+                ('desc', models.TextField(blank=True)),
+                ('used', models.BooleanField(default=True)),
+                ('order', models.IntegerField(default=0)),
+            ],
+            options={
+                'ordering': ['order', 'name'],
+                'abstract': False,
+            },
+        ),
+    ]
diff --git a/ietf/name/migrations/0033_populate_agendafiltertypename.py b/ietf/name/migrations/0033_populate_agendafiltertypename.py
new file mode 100644
index 000000000..9f450ca24
--- /dev/null
+++ b/ietf/name/migrations/0033_populate_agendafiltertypename.py
@@ -0,0 +1,33 @@
+# Generated by Django 2.2.20 on 2021-04-20 13:56
+
+from django.db import migrations
+
+
+def forward(apps, schema_editor):
+    AgendaFilterTypeName = apps.get_model('name', 'AgendaFilterTypeName')
+    names = (
+        ('none', 'None', 'Not used except for a timeslot-type column (e.g., officehours)'),
+        ('normal', 'Normal', 'Non-heading filter button'),
+        ('heading', 'Heading', 'Column heading button'),
+        ('special', 'Special', 'Button in the catch-all column'),
+    )
+    for order, (slug, name, desc) in enumerate(names):
+        AgendaFilterTypeName.objects.get_or_create(
+            slug=slug,
+            defaults=dict(name=name, desc=desc, order=order, used=True)
+        )
+
+
+def reverse(apps, schema_editor):
+    pass  # nothing to do, model about to be destroyed anyway
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('name', '0032_agendafiltertypename'),
+    ]
+
+    operations = [
+        migrations.RunPython(forward, reverse),
+    ]
diff --git a/ietf/name/migrations/0034_add_officehours_timeslottypename.py b/ietf/name/migrations/0034_add_officehours_timeslottypename.py
new file mode 100644
index 000000000..db24c4cdd
--- /dev/null
+++ b/ietf/name/migrations/0034_add_officehours_timeslottypename.py
@@ -0,0 +1,29 @@
+# Generated by Django 2.2.19 on 2021-03-29 08:28
+
+from django.db import migrations
+
+
+def forward(apps, schema_editor):
+    TimeSlotTypeName = apps.get_model('name', 'TimeSlotTypeName')
+    
+    TimeSlotTypeName.objects.get_or_create(
+        slug='officehours',
+        defaults=dict(
+            name='Office Hours',
+            desc='Office hours timeslot',
+            used=True,
+        )
+    )
+
+def reverse(apps, schema_editor):
+    pass  # don't remove the name when migrating
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('name', '0033_populate_agendafiltertypename'),
+    ]
+
+    operations = [
+        migrations.RunPython(forward, reverse),
+    ]
diff --git a/ietf/name/migrations/0035_sessionpurposename.py b/ietf/name/migrations/0035_sessionpurposename.py
new file mode 100644
index 000000000..52d3ed82c
--- /dev/null
+++ b/ietf/name/migrations/0035_sessionpurposename.py
@@ -0,0 +1,32 @@
+# Copyright The IETF Trust 2021 All Rights Reserved
+
+# Generated by Django 2.2.24 on 2021-09-16 09:42
+
+from django.db import migrations, models
+import ietf.name.models
+import jsonfield
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('name', '0034_add_officehours_timeslottypename'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='SessionPurposeName',
+            fields=[
+                ('slug', models.CharField(max_length=32, primary_key=True, serialize=False)),
+                ('name', models.CharField(max_length=255)),
+                ('desc', models.TextField(blank=True)),
+                ('used', models.BooleanField(default=True)),
+                ('order', models.IntegerField(default=0)),
+                ('timeslot_types', jsonfield.fields.JSONField(default=[], help_text='Allowed TimeSlotTypeNames', max_length=256, validators=[ietf.name.models.JSONForeignKeyListValidator('name.TimeSlotTypeName')])),
+            ],
+            options={
+                'ordering': ['order', 'name'],
+                'abstract': False,
+            },
+        ),
+    ]
diff --git a/ietf/name/migrations/0036_populate_sessionpurposename.py b/ietf/name/migrations/0036_populate_sessionpurposename.py
new file mode 100644
index 000000000..50231ce63
--- /dev/null
+++ b/ietf/name/migrations/0036_populate_sessionpurposename.py
@@ -0,0 +1,48 @@
+# Copyright The IETF Trust 2021 All Rights Reserved
+
+# Generated by Django 2.2.24 on 2021-09-16 09:42
+
+from django.db import migrations
+
+
+def forward(apps, schema_editor):
+    SessionPurposeName = apps.get_model('name', 'SessionPurposeName')
+    TimeSlotTypeName = apps.get_model('name', 'TimeSlotTypeName')
+
+    for order, (slug, name, desc, tstypes) in enumerate((
+            ('session', 'Session', 'Group session', ['regular']),
+            ('tutorial', 'Tutorial', 'Tutorial or training session', ['other']),
+            ('officehours', 'Office hours', 'Office hours session', ['other']),
+            ('coding', 'Coding', 'Coding session', ['other']),
+            ('admin', 'Administrative', 'Meeting administration', ['other', 'reg']),
+            ('social', 'Social', 'Social event or activity', ['other']),
+            ('presentation', 'Presentation', 'Presentation session', ['other', 'regular'])
+    )):
+        # verify that we're not about to use an invalid purpose
+        for ts_type in tstypes:
+            TimeSlotTypeName.objects.get(pk=ts_type)  # throws an exception unless exists
+
+        SessionPurposeName.objects.create(
+            slug=slug,
+            name=name,
+            desc=desc,
+            used=True,
+            order=order,
+            timeslot_types = tstypes
+        )
+
+
+def reverse(apps, schema_editor):
+    SessionPurposeName = apps.get_model('name', 'SessionPurposeName')
+    SessionPurposeName.objects.all().delete()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('name', '0035_sessionpurposename'),
+    ]
+
+    operations = [
+        migrations.RunPython(forward, reverse)
+    ]
diff --git a/ietf/name/models.py b/ietf/name/models.py
index 3c07c7afc..a06b78943 100644
--- a/ietf/name/models.py
+++ b/ietf/name/models.py
@@ -1,10 +1,13 @@
 # Copyright The IETF Trust 2010-2020, All Rights Reserved
 # -*- coding: utf-8 -*-
 
+import jsonfield
 
 from django.db import models
 
 from ietf.utils.models import ForeignKey
+from ietf.utils.validators import JSONForeignKeyListValidator
+
 
 class NameModel(models.Model):
     slug = models.CharField(max_length=32, primary_key=True)
@@ -64,8 +67,17 @@ class ProceedingsMaterialTypeName(NameModel):
     """social_event, host_speaker_series, supporters, wiki, additional_information"""
 class AgendaTypeName(NameModel):
     """ietf, ad, side, workshop, ..."""
+class AgendaFilterTypeName(NameModel):
+    """none, normal, heading, special"""
 class SessionStatusName(NameModel):
     """Waiting for Approval, Approved, Waiting for Scheduling, Scheduled, Cancelled, Disapproved"""
+class SessionPurposeName(NameModel):
+    """Regular, Tutorial, Office Hours, Coding, Social, Admin"""
+    timeslot_types = jsonfield.JSONField(
+        max_length=256, blank=False, default=[],
+        help_text='Allowed TimeSlotTypeNames',
+        validators=[JSONForeignKeyListValidator('name.TimeSlotTypeName')],
+    )
 class TimeSlotTypeName(NameModel):
     """Session, Break, Registration, Other, Reserved, unavail"""
     private = models.BooleanField(default=False, help_text="Whether sessions of this type should be kept off the public agenda")
diff --git a/ietf/name/resources.py b/ietf/name/resources.py
index 37d49167a..2af01bb98 100644
--- a/ietf/name/resources.py
+++ b/ietf/name/resources.py
@@ -8,7 +8,7 @@ from tastypie.cache import SimpleCache
 
 from ietf import api
 
-from ietf.name.models import ( AgendaTypeName, BallotPositionName, ConstraintName,
+from ietf.name.models import ( AgendaFilterTypeName, AgendaTypeName, BallotPositionName, ConstraintName,
     ContinentName, CountryName, DBTemplateTypeName, DocRelationshipName, DocReminderTypeName,
     DocTagName, DocTypeName, DocUrlTagName, DraftSubmissionStateName, FeedbackTypeName,
     FormalLanguageName, GroupMilestoneStateName, GroupStateName, GroupTypeName,
@@ -684,3 +684,20 @@ class ProceedingsMaterialTypeNameResource(ModelResource):
             "order": ALL,
         }
 api.name.register(ProceedingsMaterialTypeNameResource())
+
+
+class AgendaFilterTypeNameResource(ModelResource):
+    class Meta:
+        queryset = AgendaFilterTypeName.objects.all()
+        serializer = api.Serializer()
+        cache = SimpleCache()
+        #resource_name = 'agendafiltertypename'
+        ordering = ['slug', ]
+        filtering = {
+            "slug": ALL,
+            "name": ALL,
+            "desc": ALL,
+            "used": ALL,
+            "order": ALL,
+        }
+api.name.register(AgendaFilterTypeNameResource())
diff --git a/ietf/secr/meetings/forms.py b/ietf/secr/meetings/forms.py
index 0a24ee008..43cc790f8 100644
--- a/ietf/secr/meetings/forms.py
+++ b/ietf/secr/meetings/forms.py
@@ -8,8 +8,9 @@ from django.db.models import Q
 import debug                            # pyflakes:ignore
 
 from ietf.group.models import Group
+from ietf.meeting.fields import SessionPurposeAndTypeField
 from ietf.meeting.models import Meeting, Room, TimeSlot, Session, SchedTimeSessAssignment
-from ietf.name.models import TimeSlotTypeName
+from ietf.name.models import TimeSlotTypeName, SessionPurposeName
 import ietf.utils.fields
 
 
@@ -130,6 +131,13 @@ class MeetingRoomForm(forms.ModelForm):
         model = Room
         exclude = ['resources']
 
+class MeetingRoomOptionsForm(forms.Form):
+    copy_timeslots = forms.BooleanField(
+        required=False,
+        initial=False,
+        label='Duplicate timeslots from previous meeting for new rooms?',
+    )
+
 class TimeSlotForm(forms.Form):
     day = forms.ChoiceField()
     time = forms.TimeField()
@@ -163,7 +171,10 @@ class TimeSlotForm(forms.Form):
 
 class MiscSessionForm(TimeSlotForm):
     short = forms.CharField(max_length=32,label='Short Name',help_text='Enter an abbreviated session name (used for material file names)',required=False)
-    type = forms.ModelChoiceField(queryset=TimeSlotTypeName.objects.filter(used=True).exclude(slug__in=('regular',)),empty_label=None)
+    purpose = SessionPurposeAndTypeField(
+        purpose_queryset=SessionPurposeName.objects.none(),
+        type_queryset=TimeSlotTypeName.objects.none(),
+    )
     group = forms.ModelChoiceField(
         queryset=Group.objects.filter(
             Q(type__in=['ietf','team','area'],state='active')|
@@ -187,8 +198,13 @@ class MiscSessionForm(TimeSlotForm):
             self.meeting = kwargs.pop('meeting')
         if 'session' in kwargs:
             self.session = kwargs.pop('session')
+        initial = kwargs.setdefault('initial', dict())
+        initial['purpose'] = (initial.pop('purpose', ''), initial.pop('type', ''))
         super(MiscSessionForm, self).__init__(*args,**kwargs)
         self.fields['location'].queryset = Room.objects.filter(meeting=self.meeting)
+        self.fields['purpose'].purpose_queryset = SessionPurposeName.objects.filter(
+            used=True).exclude(slug='session').order_by('name')
+        self.fields['purpose'].type_queryset = TimeSlotTypeName.objects.filter(used=True)
 
     def clean(self):
         super(MiscSessionForm, self).clean()
@@ -196,13 +212,15 @@ class MiscSessionForm(TimeSlotForm):
             return
         cleaned_data = self.cleaned_data
         group = cleaned_data['group']
-        type = cleaned_data['type']
+        type = cleaned_data['purpose'].type
         short = cleaned_data['short']
         if type.slug in ('other','plenary','lead') and not group:
             raise forms.ValidationError('ERROR: a group selection is required')
         if type.slug in ('other','plenary','lead') and not short:
             raise forms.ValidationError('ERROR: a short name is required')
-            
+
+        cleaned_data['purpose'] = cleaned_data['purpose'].purpose
+        cleaned_data['type'] = type
         return cleaned_data
     
     def clean_group(self):
diff --git a/ietf/secr/meetings/views.py b/ietf/secr/meetings/views.py
index 622a2fed9..6582cb81e 100644
--- a/ietf/secr/meetings/views.py
+++ b/ietf/secr/meetings/views.py
@@ -26,7 +26,7 @@ from ietf.group.models import Group, GroupEvent
 from ietf.secr.meetings.blue_sheets import create_blue_sheets
 from ietf.secr.meetings.forms import ( BaseMeetingRoomFormSet, MeetingModelForm, MeetingSelectForm,
     MeetingRoomForm, MiscSessionForm, TimeSlotForm, RegularSessionEditForm,
-    UploadBlueSheetForm )
+    UploadBlueSheetForm, MeetingRoomOptionsForm )
 from ietf.secr.proceedings.utils import handle_upload_file
 from ietf.secr.sreq.views import get_initial_session
 from ietf.secr.utils.meeting import get_session, get_timeslot
@@ -406,9 +406,11 @@ def misc_sessions(request, meeting_id, schedule_name):
             name = form.cleaned_data['name']
             short = form.cleaned_data['short']
             type = form.cleaned_data['type']
+            purpose = form.cleaned_data['purpose']
             group = form.cleaned_data['group']
             duration = form.cleaned_data['duration']
             location = form.cleaned_data['location']
+            remote_instructions = form.cleaned_data['remote_instructions']
 
             # create TimeSlot object
             timeslot = TimeSlot.objects.create(type=type,
@@ -427,7 +429,9 @@ def misc_sessions(request, meeting_id, schedule_name):
                                              name=name,
                                              short=short,
                                              group=group,
-                                             type=type)
+                                             type=type,
+                                             purpose=purpose,
+                                             remote_instructions=remote_instructions)
 
             SchedulingEvent.objects.create(
                 session=session,
@@ -537,6 +541,7 @@ def misc_session_edit(request, meeting_id, schedule_name, slot_id):
             name = form.cleaned_data['name']
             short = form.cleaned_data['short']
             duration = form.cleaned_data['duration']
+            session_purpose = form.cleaned_data['purpose']
             slot_type = form.cleaned_data['type']
             show_location = form.cleaned_data['show_location']
             remote_instructions = form.cleaned_data['remote_instructions']
@@ -553,6 +558,8 @@ def misc_session_edit(request, meeting_id, schedule_name, slot_id):
             session.name = name
             session.short = short
             session.remote_instructions = remote_instructions
+            session.purpose = session_purpose
+            session.type = slot_type
             session.save()
 
             messages.success(request, 'Location saved')
@@ -570,7 +577,8 @@ def misc_session_edit(request, meeting_id, schedule_name, slot_id):
                    'time':slot.time.strftime('%H:%M'),
                    'duration':duration_string(slot.duration),
                    'show_location':slot.show_location,
-                   'type':slot.type,
+                   'purpose': session.purpose,
+                   'type': session.type,
                    'remote_instructions': session.remote_instructions,
                }
         form = MiscSessionForm(initial=initial, meeting=meeting, session=session)
@@ -637,29 +645,34 @@ def rooms(request, meeting_id, schedule_name):
             return redirect('ietf.secr.meetings.views.main', meeting_id=meeting_id,schedule_name=schedule_name)
 
         formset = RoomFormset(request.POST, instance=meeting, prefix='room')
-        if formset.is_valid():
+        options_form = MeetingRoomOptionsForm(request.POST)
+        if formset.is_valid() and options_form.is_valid():
             formset.save()
 
-            # if we are creating rooms for the first time create full set of timeslots
-            if first_time:
-                build_timeslots(meeting)
+            # only create timeslots on request
+            if options_form.cleaned_data['copy_timeslots']:
+                # if we are creating rooms for the first time create full set of timeslots
+                if first_time:
+                    build_timeslots(meeting)
 
-            # otherwise if we're modifying rooms
-            else:
-                # add timeslots for new rooms, deleting rooms automatically deletes timeslots
-                for form in formset.forms[formset.initial_form_count():]:
-                    if form.instance.pk:
-                        build_timeslots(meeting,room=form.instance)
+                # otherwise if we're modifying rooms
+                else:
+                    # add timeslots for new rooms, deleting rooms automatically deletes timeslots
+                    for form in formset.forms[formset.initial_form_count():]:
+                        if form.instance.pk:
+                            build_timeslots(meeting,room=form.instance)
 
             messages.success(request, 'Meeting Rooms changed successfully')
             return redirect('ietf.secr.meetings.views.rooms', meeting_id=meeting_id, schedule_name=schedule_name)
     else:
         formset = RoomFormset(instance=meeting, prefix='room')
+        options_form = MeetingRoomOptionsForm()
 
     return render(request, 'meetings/rooms.html', {
         'meeting': meeting,
         'schedule': schedule,
         'formset': formset,
+        'options_form': options_form,
         'selected': 'rooms'}
     )
 
diff --git a/ietf/secr/sreq/forms.py b/ietf/secr/sreq/forms.py
index 8cd2d91d9..a0b25c4e1 100644
--- a/ietf/secr/sreq/forms.py
+++ b/ietf/secr/sreq/forms.py
@@ -8,6 +8,7 @@ import debug                            # pyflakes:ignore
 
 from ietf.name.models import TimerangeName, ConstraintName
 from ietf.group.models import Group
+from ietf.meeting.forms import SessionDetailsFormSet
 from ietf.meeting.models import ResourceAssociation, Constraint
 from ietf.person.fields import SearchablePersonsField
 from ietf.utils.html import clean_text_field
@@ -61,13 +62,11 @@ class NameModelMultipleChoiceField(forms.ModelMultipleChoiceField):
     def label_from_instance(self, name):
         return name.desc
 
-    
+
 class SessionForm(forms.Form):
     num_session = forms.ChoiceField(choices=NUM_SESSION_CHOICES)
-    length_session1 = forms.ChoiceField(choices=LENGTH_SESSION_CHOICES)
-    length_session2 = forms.ChoiceField(choices=LENGTH_SESSION_CHOICES,required=False)
+    # session fields are added in __init__()
     session_time_relation = forms.ChoiceField(choices=SESSION_TIME_RELATION_CHOICES, required=False)
-    length_session3 = forms.ChoiceField(choices=LENGTH_SESSION_CHOICES,required=False)
     attendees = forms.IntegerField()
     # FIXME: it would cleaner to have these be
     # ModelMultipleChoiceField, and just customize the widgetry, that
@@ -84,19 +83,16 @@ class SessionForm(forms.Form):
                                                  queryset=TimerangeName.objects.all())
     adjacent_with_wg = forms.ChoiceField(required=False)
 
-    def __init__(self, group, meeting, *args, **kwargs):
+    def __init__(self, group, meeting, data=None, *args, **kwargs):
         if 'hidden' in kwargs:
             self.hidden = kwargs.pop('hidden')
         else:
             self.hidden = False
 
         self.group = group
+        self.session_forms = SessionDetailsFormSet(group=self.group, meeting=meeting, data=data)
+        super(SessionForm, self).__init__(data=data, *args, **kwargs)
 
-        super(SessionForm, self).__init__(*args, **kwargs)
-        self.fields['num_session'].widget.attrs['onChange'] = "ietf_sessions.stat_ls(this.selectedIndex);"
-        self.fields['length_session1'].widget.attrs['onClick'] = "if (ietf_sessions.check_num_session(1)) this.disabled=true;"
-        self.fields['length_session2'].widget.attrs['onClick'] = "if (ietf_sessions.check_num_session(2)) this.disabled=true;"
-        self.fields['length_session3'].widget.attrs['onClick'] = "if (ietf_sessions.check_third_session()) { this.disabled=true;}"
         self.fields['comments'].widget = forms.Textarea(attrs={'rows':'3','cols':'65'})
 
         other_groups = list(allowed_conflicting_groups().exclude(pk=group.pk).values_list('acronym', 'acronym').order_by('acronym'))
@@ -148,15 +144,8 @@ class SessionForm(forms.Form):
             )
 
         self.fields['joint_with_groups_selector'].widget.attrs['onChange'] = "document.form_post.joint_with_groups.value=document.form_post.joint_with_groups.value + ' ' + this.options[this.selectedIndex].value; return 1;"
-        self.fields['third_session'].widget.attrs['onClick'] = "if (document.form_post.num_session.selectedIndex < 2) { alert('Cannot use this field - Number of Session is not set to 2'); return false; } else { if (this.checked==true) { document.form_post.length_session3.disabled=false; } else { document.form_post.length_session3.value=0;document.form_post.length_session3.disabled=true; } }"
         self.fields["resources"].choices = [(x.pk,x.desc) for x in ResourceAssociation.objects.filter(name__used=True).order_by('name__order') ]
 
-        # check third_session checkbox if instance and length_session3
-        # assert False, (self.instance, self.fields['length_session3'].initial)
-        if self.initial and 'length_session3' in self.initial:
-            if self.initial['length_session3'] != '0' and self.initial['length_session3'] != None:
-                self.fields['third_session'].initial = True
-
         if self.hidden:
             for key in list(self.fields.keys()):
                 self.fields[key].widget = forms.HiddenInput()
@@ -235,8 +224,13 @@ class SessionForm(forms.Form):
     def clean_comments(self):
         return clean_text_field(self.cleaned_data['comments'])
 
+    def is_valid(self):
+        return super().is_valid() and self.session_forms.is_valid()
+
     def clean(self):
         super(SessionForm, self).clean()
+        self.session_forms.clean()
+
         data = self.cleaned_data
 
         # Validate the individual conflict fields
@@ -254,44 +248,45 @@ class SessionForm(forms.Form):
         for error in self._validate_duplicate_conflicts(data):
             self.add_error(None, error)
 
-        # verify session_length and num_session correspond
+        # Verify expected number of session entries are present
+        num_sessions_with_data = len(self.session_forms.forms_to_keep)
+        num_sessions_expected = -1
+        try:
+            num_sessions_expected = int(data.get('num_session', ''))
+        except ValueError:
+            self.add_error('num_session', 'Invalid value for number of sessions')
+        if len(self.session_forms.errors) == 0 and num_sessions_with_data < num_sessions_expected:
+            self.add_error('num_session', 'Must provide data for all sessions')
+
         # if default (empty) option is selected, cleaned_data won't include num_session key
-        if data.get('num_session','') == '2':
-            if not data['length_session2']:
-                self.add_error('length_session2', forms.ValidationError('You must enter a length for all sessions'))
-        else:
+        if num_sessions_expected != 2 and num_sessions_expected is not None:
             if data.get('session_time_relation'):
                 self.add_error(
                     'session_time_relation',
                     forms.ValidationError('Time between sessions can only be used when two sessions are requested.')
                 )
-            if data.get('joint_for_session') == '2':
+
+        joint_session = data.get('joint_for_session', '')
+        if joint_session != '':
+            joint_session = int(joint_session)
+            if joint_session > num_sessions_with_data:
                 self.add_error(
                     'joint_for_session',
                     forms.ValidationError(
-                        'The second session can not be the joint session, because you have not requested a second session.'
+                        f'Session {joint_session} can not be the joint session, the session has not been requested.'
                     )
                 )
 
-        if data.get('third_session', False):
-            if not data.get('length_session3',None):
-                self.add_error('length_session3', forms.ValidationError('You must enter a length for all sessions'))
-        elif data.get('joint_for_session') == '3':
-                self.add_error(
-                    'joint_for_session',
-                    forms.ValidationError(
-                        'The third session can not be the joint session, because you have not requested a third session.'
-                    )
-                )
-        
         return data
 
+    @property
+    def media(self):
+        # get media for our formset
+        return super().media + self.session_forms.media
+
 
 class VirtualSessionForm(SessionForm):
     '''A SessionForm customized for special virtual meeting requirements'''
-    length_session1 = forms.ChoiceField(choices=VIRTUAL_LENGTH_SESSION_CHOICES)
-    length_session2 = forms.ChoiceField(choices=VIRTUAL_LENGTH_SESSION_CHOICES,required=False)
-    length_session3 = forms.ChoiceField(choices=VIRTUAL_LENGTH_SESSION_CHOICES,required=False)
     attendees = forms.IntegerField(required=False)
 
 
diff --git a/ietf/secr/sreq/templatetags/ams_filters.py b/ietf/secr/sreq/templatetags/ams_filters.py
index 58db81aba..751517320 100644
--- a/ietf/secr/sreq/templatetags/ams_filters.py
+++ b/ietf/secr/sreq/templatetags/ams_filters.py
@@ -25,15 +25,17 @@ def display_duration(value):
     """
     Maps a session requested duration from select index to 
     label."""
-    map = {'0':'None',
-           '1800':'30 Minutes',
-           '3000':'50 Minutes',
-           '3600':'1 Hour',
-           '5400':'1.5 Hours',
-           '6000':'100 Minutes',
-           '7200':'2 Hours',
-           '9000':'2.5 Hours'}
-    return map[value]
+    value = int(value)
+    map = {0: 'None',
+           1800: '30 Minutes',
+           3600: '1 Hour',
+           5400: '1.5 Hours',
+           7200: '2 Hours',
+           9000: '2.5 Hours'}
+    if value in map:
+        return map[value]
+    else:
+        return "%d Hours %d Minutes %d Seconds"%(value//3600,(value%3600)//60,value%60)
 
 @register.filter
 def get_published_date(doc):
diff --git a/ietf/secr/sreq/views.py b/ietf/secr/sreq/views.py
index 121f15a00..0c912b73f 100644
--- a/ietf/secr/sreq/views.py
+++ b/ietf/secr/sreq/views.py
@@ -62,15 +62,6 @@ def get_initial_session(sessions, prune_conflicts=False):
 
     # even if there are three sessions requested, the old form has 2 in this field
     initial['num_session'] = min(sessions.count(), 2)
-
-    # accessing these foreign key fields throw errors if they are unset so we
-    # need to catch these
-    initial['length_session1'] = str(sessions[0].requested_duration.seconds)
-    try:
-        initial['length_session2'] = str(sessions[1].requested_duration.seconds)
-        initial['length_session3'] = str(sessions[2].requested_duration.seconds)
-    except IndexError:
-        pass
     initial['attendees'] = sessions[0].attendees
 
     def valid_conflict(conflict):
@@ -281,7 +272,7 @@ def confirm(request, acronym):
 
     form = FormClass(group, meeting, request.POST, hidden=True)
     form.is_valid()
-    
+
     login = request.user.person
 
     # check if request already exists for this group
@@ -316,38 +307,36 @@ def confirm(request, acronym):
     if request.method == 'POST' and button_text == 'Submit':
         # delete any existing session records with status = canceled or notmeet
         add_event_info_to_session_qs(Session.objects.filter(group=group, meeting=meeting)).filter(current_status__in=['canceled', 'notmeet']).delete()
-
-        # create new session records
-        count = 0
-        # lenth_session2 and length_session3 fields might be disabled by javascript and so
-        # wouldn't appear in form data
-        for duration in (form.data.get('length_session1',None),form.data.get('length_session2',None),form.data.get('length_session3',None)):
-            count += 1
-            if duration:
-                slug = 'apprw' if count == 3 else 'schedw'
-                new_session = Session.objects.create(
-                    meeting=meeting,
-                    group=group,
-                    attendees=form.cleaned_data['attendees'],
-                    requested_duration=datetime.timedelta(0,int(duration)),
-                    comments=form.cleaned_data['comments'],
-                    type_id='regular',
-                )
-                SchedulingEvent.objects.create(
-                    session=new_session,
-                    status=SessionStatusName.objects.get(slug=slug),
-                    by=login,
-                )
-                if 'resources' in form.data:
-                    new_session.resources.set(session_data['resources'])
-                jfs = form.data.get('joint_for_session', '-1')
-                if not jfs: # jfs might be ''
-                    jfs = '-1'
-                if int(jfs) == count:
-                    groups_split = form.cleaned_data.get('joint_with_groups').replace(',',' ').split()
-                    joint = Group.objects.filter(acronym__in=groups_split)
-                    new_session.joint_with_groups.set(joint)
-                session_changed(new_session)
+        num_sessions = int(form.cleaned_data['num_session']) + (1 if form.cleaned_data['third_session'] else 0)
+        # Create new session records
+        # Should really use sess_form.save(), but needs data from the main form as well. Need to sort that out properly.
+        for count, sess_form in enumerate(form.session_forms[:num_sessions]):
+            slug = 'apprw' if count == 3 else 'schedw'
+            new_session = Session.objects.create(
+                meeting=meeting,
+                group=group,
+                attendees=form.cleaned_data['attendees'],
+                requested_duration=sess_form.cleaned_data['requested_duration'],
+                name=sess_form.cleaned_data['name'],
+                comments=form.cleaned_data['comments'],
+                purpose=sess_form.cleaned_data['purpose'],
+                type=sess_form.cleaned_data['type'],
+            )
+            SchedulingEvent.objects.create(
+                session=new_session,
+                status=SessionStatusName.objects.get(slug=slug),
+                by=login,
+            )
+            if 'resources' in form.data:
+                new_session.resources.set(session_data['resources'])
+            jfs = form.data.get('joint_for_session', '-1')
+            if not jfs: # jfs might be ''
+                jfs = '-1'
+            if int(jfs) == count:
+                groups_split = form.cleaned_data.get('joint_with_groups').replace(',',' ').split()
+                joint = Group.objects.filter(acronym__in=groups_split)
+                new_session.joint_with_groups.set(joint)
+            session_changed(new_session)
 
         # write constraint records
         for conflictname, cfield_id in form.wg_constraint_field_ids():
@@ -418,7 +407,11 @@ def edit(request, acronym, num=None):
     '''
     meeting = get_meeting(num,days=14)
     group = get_object_or_404(Group, acronym=acronym)
-    sessions = add_event_info_to_session_qs(Session.objects.filter(group=group, meeting=meeting)).filter(Q(current_status__isnull=True) | ~Q(current_status__in=['canceled', 'notmeet'])).order_by('id')
+    sessions = add_event_info_to_session_qs(
+        Session.objects.filter(group=group, meeting=meeting)
+    ).filter(
+        Q(current_status__isnull=True) | ~Q(current_status__in=['canceled', 'notmeet', 'deleted'])
+    ).order_by('id')
     sessions_count = sessions.count()
     initial = get_initial_session(sessions)
     FormClass = get_session_form_class()
@@ -449,67 +442,68 @@ def edit(request, acronym, num=None):
         form = FormClass(group, meeting, request.POST, initial=initial)
         if form.is_valid():
             if form.has_changed():
+                form.session_forms.save()  # todo maintain event creation in commented-out old code!!
                 # might be cleaner to simply delete and rewrite all records (but maintain submitter?)
                 # adjust duration or add sessions
                 # session 1
-                if 'length_session1' in form.changed_data:
-                    session = sessions[0]
-                    session.requested_duration = datetime.timedelta(0,int(form.cleaned_data['length_session1']))
-                    session.save()
-                    session_changed(session)
-
-                # session 2
-                if 'length_session2' in form.changed_data:
-                    length_session2 = form.cleaned_data['length_session2']
-                    if length_session2 == '':
-                        sessions[1].delete()
-                    elif sessions_count < 2:
-                        duration = datetime.timedelta(0,int(form.cleaned_data['length_session2']))
-                        new_session = Session.objects.create(
-                            meeting=meeting,
-                            group=group,
-                            attendees=form.cleaned_data['attendees'],
-                            requested_duration=duration,
-                            comments=form.cleaned_data['comments'],
-                            type_id='regular',
-                        )
-                        SchedulingEvent.objects.create(
-                            session=new_session,
-                            status=SessionStatusName.objects.get(slug='schedw'),
-                            by=request.user.person,
-                        )
-                    else:
-                        duration = datetime.timedelta(0,int(form.cleaned_data['length_session2']))
-                        session = sessions[1]
-                        session.requested_duration = duration
-                        session.save()
-
-                # session 3
-                if 'length_session3' in form.changed_data:
-                    length_session3 = form.cleaned_data['length_session3']
-                    if length_session3 == '':
-                        sessions[2].delete()
-                    elif sessions_count < 3:
-                        duration = datetime.timedelta(0,int(form.cleaned_data['length_session3']))
-                        new_session = Session.objects.create(
-                            meeting=meeting,
-                            group=group,
-                            attendees=form.cleaned_data['attendees'],
-                            requested_duration=duration,
-                            comments=form.cleaned_data['comments'],
-                            type_id='regular',
-                        )
-                        SchedulingEvent.objects.create(
-                            session=new_session,
-                            status=SessionStatusName.objects.get(slug='apprw'),
-                            by=request.user.person,
-                        )
-                    else:
-                        duration = datetime.timedelta(0,int(form.cleaned_data['length_session3']))
-                        session = sessions[2]
-                        session.requested_duration = duration
-                        session.save()
-                        session_changed(session)
+                # if 'length_session1' in form.changed_data:
+                #     session = sessions[0]
+                #     session.requested_duration = datetime.timedelta(0,int(form.cleaned_data['length_session1']))
+                #     session.save()
+                #     session_changed(session)
+                #
+                # # session 2
+                # if 'length_session2' in form.changed_data:
+                #     length_session2 = form.cleaned_data['length_session2']
+                #     if length_session2 == '':
+                #         sessions[1].delete()
+                #     elif sessions_count < 2:
+                #         duration = datetime.timedelta(0,int(form.cleaned_data['length_session2']))
+                #         new_session = Session.objects.create(
+                #             meeting=meeting,
+                #             group=group,
+                #             attendees=form.cleaned_data['attendees'],
+                #             requested_duration=duration,
+                #             comments=form.cleaned_data['comments'],
+                #             type_id='regular',
+                #         )
+                #         SchedulingEvent.objects.create(
+                #             session=new_session,
+                #             status=SessionStatusName.objects.get(slug='schedw'),
+                #             by=request.user.person,
+                #         )
+                #     else:
+                #         duration = datetime.timedelta(0,int(form.cleaned_data['length_session2']))
+                #         session = sessions[1]
+                #         session.requested_duration = duration
+                #         session.save()
+                #
+                # # session 3
+                # if 'length_session3' in form.changed_data:
+                #     length_session3 = form.cleaned_data['length_session3']
+                #     if length_session3 == '':
+                #         sessions[2].delete()
+                #     elif sessions_count < 3:
+                #         duration = datetime.timedelta(0,int(form.cleaned_data['length_session3']))
+                #         new_session = Session.objects.create(
+                #             meeting=meeting,
+                #             group=group,
+                #             attendees=form.cleaned_data['attendees'],
+                #             requested_duration=duration,
+                #             comments=form.cleaned_data['comments'],
+                #             type_id='regular',
+                #         )
+                #         SchedulingEvent.objects.create(
+                #             session=new_session,
+                #             status=SessionStatusName.objects.get(slug='apprw'),
+                #             by=request.user.person,
+                #         )
+                #     else:
+                #         duration = datetime.timedelta(0,int(form.cleaned_data['length_session3']))
+                #         session = sessions[2]
+                #         session.requested_duration = duration
+                #         session.save()
+                #         session_changed(session)
 
                 # New sessions may have been created, refresh the sessions list
                 sessions = add_event_info_to_session_qs(
diff --git a/ietf/secr/static/secr/js/session_purpose_and_type_widget.js b/ietf/secr/static/secr/js/session_purpose_and_type_widget.js
new file mode 100644
index 000000000..692f45f00
--- /dev/null
+++ b/ietf/secr/static/secr/js/session_purpose_and_type_widget.js
@@ -0,0 +1,83 @@
+/* Copyright The IETF Trust 2021, All Rights Reserved
+ *
+ * JS support for the SessionPurposeAndTypeWidget
+ * */
+(function() {
+  'use strict';
+
+  /* Find elements that are parts of the session details widgets. This is an
+  * HTMLCollection that will update if the DOM changes, so ok to evaluate immediately. */
+  const widget_elements = document.getElementsByClassName('session_purpose_widget');
+
+  /* Find the id prefix for each widget. Individual elements have a _<number> suffix. */
+  function get_widget_ids(elements) {
+    const ids = new Set();
+    for (let ii=0; ii < elements.length; ii++) {
+      const parts = elements[ii].id.split('_');
+      parts.pop();
+      ids.add(parts.join('_'));
+    }
+    return ids;
+  }
+
+  /* Set the 'type' element to a type valid for the currently selected purpose, if possible */
+  function set_valid_type(type_elt, purpose, allowed_types) {
+    const valid_types = allowed_types[purpose] || [];
+    if (valid_types.indexOf(type_elt.value) === -1) {
+      type_elt.value = (valid_types.length > 0) ? valid_types[0] : '';
+    }
+  }
+
+  /* Hide any type options not allowed for the selected purpose */
+  function update_type_option_visibility(type_option_elts, purpose, allowed_types) {
+    const valid_types = allowed_types[purpose] || [];
+    for (const elt of type_option_elts) {
+      if (valid_types.indexOf(elt.value) === -1) {
+        elt.setAttribute('hidden', 'hidden');
+      } else {
+        elt.removeAttribute('hidden');
+      }
+    }
+  }
+
+  /* Update visibility of 'type' select so it is only shown when multiple options are available */
+  function update_widget_visibility(elt, purpose, allowed_types) {
+    const valid_types = allowed_types[purpose] || [];
+    if (valid_types.length > 1) {
+      elt.removeAttribute('hidden'); // make visible
+    } else {
+      elt.setAttribute('hidden', 'hidden'); // make invisible
+    }
+  }
+
+  /* Update the 'type' select to reflect a change in the selected purpose */
+  function update_type_element(type_elt, purpose, type_options, allowed_types) {
+    update_widget_visibility(type_elt, purpose, allowed_types);
+    update_type_option_visibility(type_options, purpose, allowed_types);
+    set_valid_type(type_elt, purpose, allowed_types);
+  }
+
+  /* Factory for event handler with a closure */
+  function purpose_change_handler(type_elt, type_options, allowed_types) {
+    return function(event) {
+      update_type_element(type_elt, event.target.value, type_options, allowed_types);
+    };
+  }
+
+  /* Initialization */
+  function on_load() {
+    for (const widget_id of get_widget_ids(widget_elements)) {
+      const purpose_elt = document.getElementById(widget_id + '_0');
+      const type_elt = document.getElementById(widget_id + '_1');
+      const type_options = type_elt.getElementsByTagName('option');
+      const allowed_types = JSON.parse(type_elt.dataset.allowedOptions);
+
+      purpose_elt.addEventListener(
+        'change',
+        purpose_change_handler(type_elt, type_options, allowed_types)
+      );
+      update_type_element(type_elt, purpose_elt.value, type_options, allowed_types);
+    }
+  }
+  window.addEventListener('load', on_load, false);
+})();
\ No newline at end of file
diff --git a/ietf/secr/static/secr/js/sessions.js b/ietf/secr/static/secr/js/sessions.js
index 4635c5603..a2770e626 100644
--- a/ietf/secr/static/secr/js/sessions.js
+++ b/ietf/secr/static/secr/js/sessions.js
@@ -1,59 +1,52 @@
 // Copyright The IETF Trust 2015-2021, All Rights Reserved
+/* global alert */
 var ietf_sessions; // public interface
 
 (function() {
   'use strict';
 
-  function stat_ls (val){
-    if (val == 0) {
-      document.form_post.length_session1.disabled = true;
-      document.form_post.length_session2.disabled = true;
-      if (document.form_post.length_session3) { document.form_post.length_session3.disabled = true; }
-      document.form_post.session_time_relation.disabled = true;
-      document.form_post.joint_for_session.disabled = true;
-      document.form_post.length_session1.value = 0;
-      document.form_post.length_session2.value = 0;
-      document.form_post.length_session3.value = 0;
-      document.form_post.session_time_relation.value = '';
-      document.form_post.joint_for_session.value = '';
-      document.form_post.third_session.checked=false;
-    }
-    if (val == 1) {
-      document.form_post.length_session1.disabled = false;
-      document.form_post.length_session2.disabled = true;
-      if (document.form_post.length_session3) { document.form_post.length_session3.disabled = true; }
-      document.form_post.session_time_relation.disabled = true;
-      document.form_post.joint_for_session.disabled = true;
-      document.form_post.length_session2.value = 0;
-      document.form_post.length_session3.value = 0;
-      document.form_post.session_time_relation.value = '';
-      document.form_post.joint_for_session.value = '1';
-      document.form_post.third_session.checked=false;
-    }
-    if (val == 2) {
-      document.form_post.length_session1.disabled = false;
-      document.form_post.length_session2.disabled = false;
-      if (document.form_post.length_session3) { document.form_post.length_session3.disabled = false; }
-      document.form_post.session_time_relation.disabled = false;
-      document.form_post.joint_for_session.disabled = false;
+  function get_formset_management_data(prefix) {
+    return {
+      total_forms: document.getElementById('id_' + prefix + '-TOTAL_FORMS').value,
+    };
+  }
+
+  function update_session_form_visibility(session_num, is_visible) {
+    const elt = document.getElementById('session_row_' + session_num);
+    if (elt) {
+      elt.hidden = !is_visible;
+      elt.querySelector('[name$="DELETE"]').value = is_visible ? '' : 'on';
     }
   }
 
-  function check_num_session (val) {
-    if (document.form_post.num_session.value < val) {
-      alert("Please change the value in the Number of Sessions to use this field");
-      document.form_post.num_session.focused = true;
-      return true;
-    }
-    return false;
+  function have_additional_session() {
+    const elt = document.getElementById('id_third_session');
+    return elt && elt.checked;
   }
 
-  function check_third_session () {
-    if (document.form_post.third_session.checked == false) {
-
-      return true;
+  function update_for_num_sessions(val) {
+    const total_forms = get_formset_management_data('session_set').total_forms;
+    val = Number(val);
+    if (have_additional_session()) {
+      val++;
+    }
+
+    for (let i=0; i < total_forms; i++) {
+      update_session_form_visibility(i, i < val);
+    }
+
+    const only_one_session = (val === 1);
+    if (document.form_post.session_time_relation) {
+      document.form_post.session_time_relation.disabled = only_one_session;
+      document.form_post.session_time_relation.closest('tr').hidden = only_one_session;
+    }
+    if (document.form_post.joint_for_session) {
+      document.form_post.joint_for_session.disabled = only_one_session;
+    }
+    const third_session_row = document.getElementById('third_session_row');
+    if (third_session_row) {
+      third_session_row.hidden = val < 2;
     }
-    return false;
   }
 
   function delete_last_joint_with_groups () {
@@ -114,7 +107,39 @@ var ietf_sessions; // public interface
     delete_last_wg_constraint(slug);
   }
 
+  /**
+   * Handler for the change event on the session count select or 'third session' checkbox
+   */
+  function handle_num_session_change(event) {
+    const num_select_value = Number(event.target.value);
+    if (num_select_value !== 2) {
+      if (document.form_post.third_session) {
+        document.form_post.third_session.checked = false;
+      }
+    }
+    update_for_num_sessions(num_select_value);
+  }
+
+  function handle_third_session_change(event) {
+    const num_select_value = Number(document.getElementById('id_num_session').value);
+    if (num_select_value === 2) {
+      update_for_num_sessions(num_select_value);
+    } else {
+      event.target.checked = false;
+    }
+  }
+
+  /* Initialization */
   function on_load() {
+    // Attach event handler to session count select
+    const num_session_select = document.getElementById('id_num_session');
+    num_session_select.addEventListener('change', handle_num_session_change);
+    const third_session_input = document.getElementById('id_third_session');
+    if (third_session_input) {
+      third_session_input.addEventListener('change', handle_third_session_change);
+    }
+    update_for_num_sessions(num_session_select.value);
+
     // Attach event handlers to constraint selectors
     let selectors = document.getElementsByClassName('wg_constraint_selector');
     for (let index = 0; index < selectors.length; index++) {
@@ -128,9 +153,6 @@ var ietf_sessions; // public interface
 
   // expose public interface methods
   ietf_sessions = {
-    stat_ls: stat_ls,
-    check_num_session: check_num_session,
-    check_third_session: check_third_session,
     delete_last_joint_with_groups: delete_last_joint_with_groups,
     delete_wg_constraint_clicked: delete_wg_constraint_clicked
   }
diff --git a/ietf/secr/templates/includes/sessions_request_form.html b/ietf/secr/templates/includes/sessions_request_form.html
index 5c4f3e91d..256c91670 100755
--- a/ietf/secr/templates/includes/sessions_request_form.html
+++ b/ietf/secr/templates/includes/sessions_request_form.html
@@ -1,20 +1,27 @@
          <span class="required">*</span> Required Field
          <form id="session-request-form" action="." method="post" name="form_post">{% csrf_token %}
+         {{ form.session_forms.management_form }}
          {% if form.non_field_errors %}{{ form.non_field_errors }}{% endif %}
          <table id="sessions-new-table" cellspacing="1" cellpadding="1" border="0">
            <col width="150">
            <tr class="bg1"><td>Working Group Name:</td><td>{{ group.name }} ({{ group.acronym }})</td></tr>
            <tr class="bg2"><td>Area Name:</td><td>{% if group.parent %}{{ group.parent.name }} ({{ group.parent.acronym }}){% endif %}</td></tr>
            <tr class="bg1"><td>Number of Sessions:<span class="required">*</span></td><td>{{ form.num_session.errors }}{{ form.num_session }}</td></tr>
-           <tr class="bg2"><td>Length of Session 1:<span class="required">*</span></td><td>{{ form.length_session1.errors }}{{ form.length_session1 }}</td></tr>
-           <tr class="bg2"><td>Length of Session 2:<span class="required">*</span></td><td>{{ form.length_session2.errors }}{{ form.length_session2 }}</td></tr>
+           <tr class="bg2" id="session_row_0"><td>Session 1:<span class="required">*</span></td><td>{% include 'meeting/session_details_form.html' with form=form.session_forms.0 only %}</td></tr>
+           <tr class="bg2" id="session_row_1"><td>Session 2:<span class="required">*</span></td><td>{% include 'meeting/session_details_form.html' with form=form.session_forms.1 only %}</td></tr>
            {% if not is_virtual %}
             <tr class="bg2"><td>Time between two sessions:</td><td>{{ form.session_time_relation.errors }}{{ form.session_time_relation }}</td></tr>
            {% endif %}
            {% if group.type.slug == "wg" %}
-             <tr class="bg2"><td>Additional Session Request:</td><td>{{ form.third_session }} Check this box to request an additional session.<br>
+             <tr class="bg2" id="third_session_row"><td>Additional Session Request:</td><td>{{ form.third_session }} Check this box to request an additional session.<br>
              Additional slot may be available after agenda scheduling has closed and with the approval of an Area Director.<br>
-             Length of Third Session: {{ form.length_session3.errors }}{{ form.length_session3 }}</td></tr>
+             <div id="session_row_2">
+               Third Session:
+               {% include 'meeting/session_details_form.html' with form=form.session_forms.2 only %}
+             </div>
+             </td></tr>
+           {% else %}{# else group.type.slug != "wg" #}
+             {% include 'meeting/session_details_form.html' with form=form.session_forms.2 hidden=True only %}
            {% endif %}
            <tr class="bg1"><td>Number of Attendees:{% if not is_virtual %}<span class="required">*</span>{% endif %}</td><td>{{ form.attendees.errors }}{{ form.attendees }}</td></tr>
            <tr class="bg2"><td>People who must be present:</td><td>{{ form.bethere.errors }}{{ form.bethere }}</td></tr>
diff --git a/ietf/secr/templates/includes/sessions_request_view.html b/ietf/secr/templates/includes/sessions_request_view.html
index 595b69e3d..7d842eb7a 100644
--- a/ietf/secr/templates/includes/sessions_request_view.html
+++ b/ietf/secr/templates/includes/sessions_request_view.html
@@ -4,16 +4,24 @@
         <tr class="row1"><td>Working Group Name:</td><td>{{ group.name }} ({{ group.acronym }})</td></tr>
         <tr class="row2"><td>Area Name:</td><td>{{ group.parent }}</td></tr>
         <tr class="row1"><td>Number of Sessions Requested:</td><td>{% if session.length_session3 %}3{% else %}{{ session.num_session }}{% endif %}</td></tr>
-        <tr class="row2"><td>Length of Session 1:</td><td>{{ session.length_session1|display_duration }}</td></tr>
-        {% if session.length_session2 %}
-          <tr class="row2"><td>Length of Session 2:</td><td>{{ session.length_session2|display_duration }}</td></tr>
-          {% if not is_virtual %}
+        {% for sess_form in form.session_forms %}{% if sess_form.cleaned_data and not sess_form.cleaned_data.DELETE %}
+        <tr class="row2"><td>Session {{ forloop.counter }}:</td><td>
+          <dl>
+            <dt>Length</dt><dd>{{ sess_form.cleaned_data.requested_duration.total_seconds|display_duration }}</dd>
+            {% if sess_form.cleaned_data.name %}<dt>Name</dt><dd>{{ sess_form.cleaned_data.name }}</dd>{% endif %}
+            {% if sess_form.cleaned_data.purpose.slug != 'session' %}
+              <dt>Purpose</dt>
+              <dd>
+                {{ sess_form.cleaned_data.purpose }}
+                {% if sess_form.cleaned_data.purpose.timeslot_types|length > 1 %}({{ sess_form.cleaned_data.type }}{% endif %}
+              </dd>
+            {% endif %}
+          </dl>
+        </td></tr>
+          {% if forloop.counter == 2 and not is_virtual %}
             <tr class="row2"><td>Time between sessions:</td><td>{% if session.session_time_relation_display %}{{ session.session_time_relation_display }}{% else %}No preference{% endif %}</td></tr>
           {% endif %}
-        {% endif %}
-        {% if session.length_session3 %}
-          <tr class="row2"><td>Length of Session 3:</td><td>{{ session.length_session3|display_duration }}</td></tr>
-        {% endif %}
+        {% endif %}{% endfor %}
         <tr class="row1"><td>Number of Attendees:</td><td>{{ session.attendees }}</td></tr>
         <tr class="row2">
           <td>Conflicts to Avoid:</td>
diff --git a/ietf/secr/templates/meetings/misc_session_edit.html b/ietf/secr/templates/meetings/misc_session_edit.html
index 2bf666a0e..4544695cd 100755
--- a/ietf/secr/templates/meetings/misc_session_edit.html
+++ b/ietf/secr/templates/meetings/misc_session_edit.html
@@ -21,3 +21,11 @@
 </div> <!-- module -->
 
 {% endblock %}
+
+{% block extrahead %}
+  {{ block.super }}
+  {{ form.media.js }}
+{% endblock %}
+{% block extrastyle %}
+  {{ form.media.css }}
+{% endblock %}
\ No newline at end of file
diff --git a/ietf/secr/templates/meetings/misc_sessions.html b/ietf/secr/templates/meetings/misc_sessions.html
index c946c338e..1c370b1ef 100644
--- a/ietf/secr/templates/meetings/misc_sessions.html
+++ b/ietf/secr/templates/meetings/misc_sessions.html
@@ -1,5 +1,5 @@
 {% extends "meetings/base_rooms_times.html" %}
-
+{% load agenda_custom_tags %}
 {% block subsection %}
 
 <div class="module">
@@ -27,12 +27,12 @@
           <tr class="{% cycle 'row1' 'row2' %}{% ifchanged assignment.session.type %} break{% endifchanged %}{% if assignment.current_session_status == "canceled" %} cancelled{% endif %}">
             <td>{{ assignment.timeslot.time|date:"D" }}</td>
             <td>{{ assignment.timeslot.time|date:"H:i" }}-{{ assignment.timeslot.end_time|date:"H:i" }}</td>
-            <td>{{ assignment.timeslot.name }}</td>
+            <td>{% assignment_display_name assignment %}</td>
             <td>{{ assignment.session.short }}</td>
             <td>{{ assignment.session.group.acronym }}</td>
             <td>{{ assignment.timeslot.location }}</td>
             <td>{{ assignment.timeslot.show_location }}</td>
-            <td>{{ assignment.timeslot.type }}</td>
+            <td>{% with purpose=assignment.session.purpose %}{{ purpose }}{% if purpose.timeslot_types|length > 1 %} ({{ assignment.slot_type }}){% endif %}{% endwith %}</td>
             {% if assignment.schedule_id == schedule.pk %}
               <td><a href="{% url "ietf.secr.meetings.views.misc_session_edit" meeting_id=meeting.number schedule_name=schedule.name slot_id=assignment.timeslot.id %}">Edit</a></td>
               <td>
@@ -49,7 +49,7 @@
       </tbody>
     </table>
     {% else %}
-      <h3>No timeslots exist for this meeting.  First add the rooms and then the app will create timeslots based on the schedule from the last meeting.</h3>
+      <h3>No timeslots exist for this meeting. Add rooms with the "duplicate timeslots" option enabled to copy timeslots from the last meeting.</h3>
     {% endif %}
     <br /><hr />
     
@@ -74,3 +74,11 @@
 
 
 {% endblock %}
+
+{% block extrahead %}
+  {{ block.super }}
+  {{ form.media.js }}
+{% endblock %}
+{% block extrastyle %}
+  {{ form.media.css }}
+{% endblock %}
\ No newline at end of file
diff --git a/ietf/secr/templates/meetings/rooms.html b/ietf/secr/templates/meetings/rooms.html
index 643be358c..ed1d31fb9 100644
--- a/ietf/secr/templates/meetings/rooms.html
+++ b/ietf/secr/templates/meetings/rooms.html
@@ -11,7 +11,8 @@
             <form id="meetings-meta-rooms" action="" method="post">{% csrf_token %}
               {{ formset.management_form }}
               {{ formset.non_form_errors }}
-              
+              {% if options_form %}{{ options_form.errors }}{% endif %}
+
               <table id="id_rooms_table" class="full-width">
                 <thead>
                  <tr>
@@ -43,9 +44,10 @@
           </div> <!-- iniline-related -->
         </div> <!-- inline-group -->
         
-        {% include "includes/buttons_save.html" %}
+  {% if options_form %}{{ options_form }}{% endif %}
+  {% include "includes/buttons_save.html" %}
       
-      </form>
+  </form>
 </div> <!-- module -->
 
 {% endblock %}
diff --git a/ietf/secr/templates/meetings/times.html b/ietf/secr/templates/meetings/times.html
index 6d1a19736..56e6a4f8a 100644
--- a/ietf/secr/templates/meetings/times.html
+++ b/ietf/secr/templates/meetings/times.html
@@ -29,7 +29,7 @@
       </tbody>
     </table>
     {% else %}
-      <h3>No timeslots exist for this meeting.  First add the rooms and then the app will create timeslots based on the schedule from the last meeting.</h3>
+      <h3>No timeslots exist for this meeting. Add rooms with the "duplicate timeslots" option enabled to copy timeslots from the last meeting.</h3>
     {% endif %}
     <br /><hr />
     
diff --git a/ietf/secr/templates/sreq/confirm.html b/ietf/secr/templates/sreq/confirm.html
index 2bf472d31..141b5cbce 100755
--- a/ietf/secr/templates/sreq/confirm.html
+++ b/ietf/secr/templates/sreq/confirm.html
@@ -3,8 +3,18 @@
 
 {% block title %}Sessions - Confirm{% endblock %}
 
+{% block extrastyle %}
+<style>
+  dl {width: 100%;}
+  dt {float: left; width: 15%; margin: 0.1em 0 0.1em 0; }
+  dt::after {content: ":";}
+  dd {float: left; width: 85%; margin: 0.1em 0 0.1em 0;}
+</style>
+{% endblock %}
+
 {% block extrahead %}{{ block.super }}
   <script type="text/javascript" src="{% static 'secr/js/utils.js' %}"></script>
+  {{ form.media }}
 {% endblock %}
 
 {% block breadcrumbs %}{{ block.super }} 
@@ -20,7 +30,7 @@
 
     {% include "includes/sessions_request_view.html" %}
     
-    {% if session.length_session3 %}
+    {% if form.session_forms.forms_to_keep|length > 2 %}
       <br>
             <span class="alert"><p><b>Note: Your request for a third session must be approved by an area director before
             being submitted to agenda@ietf.org.  Click "Submit" below to email an approval
@@ -30,6 +40,8 @@
     
     <form action="{% url "ietf.secr.sreq.views.confirm" acronym=group.acronym %}" method="post">{% csrf_token %}
       {{ form }}
+      {{ form.session_forms.management_form }}
+      {% for sf in form.session_forms %}{% include 'meeting/session_details_form.html' with form=sf hidden=True only %}{% endfor %}
       {% include "includes/buttons_submit_cancel.html" %}
     </form>
 
diff --git a/ietf/settings.py b/ietf/settings.py
index 53614017c..75ec5bc89 100644
--- a/ietf/settings.py
+++ b/ietf/settings.py
@@ -959,8 +959,11 @@ INTERNET_DRAFT_DAYS_TO_EXPIRE = 185
 
 FLOORPLAN_MEDIA_DIR = 'floor'
 FLOORPLAN_DIR = os.path.join(MEDIA_ROOT, FLOORPLAN_MEDIA_DIR)
+FLOORPLAN_LEGACY_BASE_URL = 'https://tools.ietf.org/agenda/{meeting.number}/venue/'
+FLOORPLAN_LAST_LEGACY_MEETING = 95  # last meeting to use FLOORPLAN_LEGACY_BASE_URL
 
 MEETING_USES_CODIMD_DATE = datetime.date(2020,7,6)
+MEETING_LEGACY_OFFICE_HOURS_END = 111  # last meeting to use legacy office hours representation
 
 # Maximum dimensions to accept at all
 MEETINGHOST_LOGO_MAX_UPLOAD_WIDTH = 400
diff --git a/ietf/static/ietf/css/ietf.css b/ietf/static/ietf/css/ietf.css
index 3f48b6d9b..02f8614e2 100644
--- a/ietf/static/ietf/css/ietf.css
+++ b/ietf/static/ietf/css/ietf.css
@@ -1545,6 +1545,43 @@ a.fc-event, .fc-event, .fc-content, .fc-title, .fc-event-container {
   width: 10em;
 }
 
+.timeslot-edit .tstable div.timeslot {
+    border: #000000 solid 1px;
+    border-radius: 0.5em;
+    padding: 0.5em;
+}
+
+.timeslot-edit .tstable .timeslot .ts-name {
+    overflow: hidden;
+}
+.timeslot-edit .tstable .timeslot .ts-type {
+    font-size: smaller;
+}
+
+.timeslot-edit .tstable .timeslot .timeslot-buttons {
+    float: right;
+}
+
+.timeslot-edit .tstable .timeslot.in-official-use {
+    background-color: #d9edf7;
+}
+
+.timeslot-edit .tstable .timeslot.in-unofficial-use {
+    background-color: #f8f8e0;
+}
+
+.timeslot-edit .tstable td.timeslot-collision {
+    background-color: #ffa0a0;
+}
+
+.timeslot-edit .tstable .tstype_unavail {
+    background-color:#666;
+}
+
+.timeslot-edit .official-use-warning {
+    color: #ff0000;
+}
+
 .rightmarker, .leftmarker {
     width: 3px;
     padding-right: 0px !important;
diff --git a/ietf/static/ietf/js/meeting/session_details_form.js b/ietf/static/ietf/js/meeting/session_details_form.js
new file mode 100644
index 000000000..28907bf92
--- /dev/null
+++ b/ietf/static/ietf/js/meeting/session_details_form.js
@@ -0,0 +1,109 @@
+/* Copyright The IETF Trust 2021, All Rights Reserved
+ *
+ * JS support for the SessionDetailsForm
+ * */
+(function() {
+  'use strict';
+
+  /* Find the id prefix for each widget. Individual elements have a _<number> suffix. */
+  function get_widget_ids(elements) {
+    const ids = new Set();
+    for (let ii=0; ii < elements.length; ii++) {
+      const parts = elements[ii].id.split('_');
+      parts.pop();
+      ids.add(parts.join('_'));
+    }
+    return ids;
+  }
+
+  /* Set the 'type' element to a type valid for the currently selected purpose, if possible */
+  function set_valid_type(type_elt, purpose, allowed_types) {
+    const valid_types = allowed_types[purpose] || [];
+    if (valid_types.indexOf(type_elt.value) === -1) {
+      type_elt.value = (valid_types.length > 0) ? valid_types[0] : '';
+    }
+  }
+
+  /* Hide any type options not allowed for the selected purpose */
+  function update_type_option_visibility(type_option_elts, purpose, allowed_types) {
+    const valid_types = allowed_types[purpose] || [];
+    for (const elt of type_option_elts) {
+      if (valid_types.indexOf(elt.value) === -1) {
+        elt.setAttribute('hidden', 'hidden');
+      } else {
+        elt.removeAttribute('hidden');
+      }
+    }
+  }
+
+  /* Update visibility of 'type' select so it is only shown when multiple options are available */
+  function update_widget_visibility(elt, purpose, allowed_types) {
+    const valid_types = allowed_types[purpose] || [];
+    if (valid_types.length > 1) {
+      elt.removeAttribute('hidden'); // make visible
+    } else {
+      elt.setAttribute('hidden', 'hidden'); // make invisible
+    }
+  }
+
+  /* Update the 'type' select to reflect a change in the selected purpose */
+  function update_type_element(type_elt, purpose, type_options, allowed_types) {
+    update_widget_visibility(type_elt, purpose, allowed_types);
+    update_type_option_visibility(type_options, purpose, allowed_types);
+    set_valid_type(type_elt, purpose, allowed_types);
+  }
+
+  function update_name_field_visibility(name_elt, purpose) {
+    const row = name_elt.closest('tr');
+    if (purpose === 'session') {
+      row.setAttribute('hidden', 'hidden');
+    } else {
+      row.removeAttribute('hidden');
+    }
+  }
+
+  /* Factory for event handler with a closure */
+  function purpose_change_handler(name_elt, type_elt, type_options, allowed_types) {
+    return function(event) {
+      const purpose = event.target.value;
+      update_name_field_visibility(name_elt, purpose);
+      update_type_element(type_elt, purpose, type_options, allowed_types);
+    };
+  }
+
+  function add_purpose_change_handler(form) {
+    const id_prefix = 'id_' + form.dataset.prefix;
+    const name_elt = document.getElementById(id_prefix + '-name');
+    const purpose_elt = document.getElementById(id_prefix + '-purpose');
+    const type_elt = document.getElementById(id_prefix + '-type');
+    const type_options = type_elt.getElementsByTagName('option');
+    const allowed_types = JSON.parse(type_elt.dataset.allowedOptions);
+
+    // update on future changes
+    purpose_elt.addEventListener(
+      'change',
+      purpose_change_handler(name_elt, type_elt, type_options, allowed_types)
+    );
+
+    // update immediately
+    update_type_element(type_elt, purpose_elt.value, type_options, allowed_types);
+    update_name_field_visibility(name_elt, purpose_elt.value);
+
+    // hide the purpose selector if only one option
+    const purpose_options = purpose_elt.querySelectorAll('option:not([value=""])');
+    if (purpose_options.length < 2) {
+      purpose_elt.closest('tr').setAttribute('hidden', 'hidden');
+    }
+  }
+
+  /* Initialization */
+  function on_load() {
+    /* Find elements that are parts of the session details forms. This is an
+    * HTMLCollection that will update if the DOM changes, so ok to evaluate immediately. */
+    const forms = document.getElementsByClassName('session-details-form');
+    for (const form of forms) {
+      add_purpose_change_handler(form);
+    }
+  }
+  window.addEventListener('load', on_load, false);
+})();
\ No newline at end of file
diff --git a/ietf/templates/meeting/agenda.html b/ietf/templates/meeting/agenda.html
index 70f6fdbd8..8a74420c3 100644
--- a/ietf/templates/meeting/agenda.html
+++ b/ietf/templates/meeting/agenda.html
@@ -4,7 +4,7 @@
 {% load static %}
 {% load ietf_filters %}
 {% load textfilters %}
-{% load htmlfilters %}
+{% load htmlfilters agenda_custom_tags%}
 
 {% block title %}
   IETF {{ schedule.meeting.number }} meeting agenda
@@ -143,119 +143,105 @@
               </tr>
             {% endifchanged %}
 
-            {% if item.timeslot.type_id == 'regular' %}
-              {% ifchanged %}
-                <tr class="info session-label-row"
-                    data-slot-start-ts="{{item.start_timestamp}}"
-                    data-slot-end-ts="{{item.end_timestamp}}">
-                  <td class="leftmarker"></td>
-	          <th class="text-nowrap text-right">
+              {% if item|is_special_agenda_item %}
+                  <tr id="row-{{ item.slug }}" data-filter-keywords="{{ item.filter_keywords|join:',' }}"
+                      data-slot-start-ts="{{item.start_timestamp}}"
+                      data-slot-end-ts="{{item.end_timestamp}}">
+                      <td class="leftmarker"></td>
+                      <td class="text-nowrap text-right">
                     <span class="hidden-xs">
                        {% include "meeting/timeslot_start_end.html" %}
                     </span>
-	          </th>
-	          <th colspan="4">
-                    <span class="hidden-sm hidden-md hidden-lg">
-                       {% include "meeting/timeslot_start_end.html" %}
-                    </span>
-	            {{ item.timeslot.time|date:"l"}}
-	            {{item.timeslot.name|capfirst_allcaps}}
-	          </th>
-                  <td class="rightmarker"></td>
-                </tr>
-              {% endifchanged %}
-            {% endif %}
-
-            {% if item.timeslot.type.slug == 'break' or item.timeslot.type.slug == 'reg' or item.timeslot.type.slug == 'other' %}
-                <tr id="row-{{ item.slug }}" data-filter-keywords="{{ item.filter_keywords|join:',' }}"
-                    data-slot-start-ts="{{item.start_timestamp}}"
-                    data-slot-end-ts="{{item.end_timestamp}}">
-                  <td class="leftmarker"></td>
-	          <td class="text-nowrap text-right">
-                    <span class="hidden-xs">
-                       {% include "meeting/timeslot_start_end.html" %}
-                    </span>
-	          </td>
-                  <td colspan="3">
-                    <span class="hidden-sm hidden-md hidden-lg">
-                       {% include "meeting/timeslot_start_end.html" %}
-                    </span>
-                    {% if item.timeslot.show_location and item.timeslot.get_html_location %}
-		      {% if schedule.meeting.number|add:"0" < 96 %}
-                      <a href="https://tools.ietf.org/agenda/{{schedule.meeting.number}}/venue/?room={{ item.timeslot.get_html_location|xslugify }}">{{item.timeslot.get_html_location}}</a>
-                      {% elif item.timeslot.location.floorplan %}
-		      <a href="{% url 'ietf.meeting.views.floor_plan' num=schedule.meeting.number %}?room={{ item.timeslot.get_html_location|xslugify }}">{{item.timeslot.get_html_location}}</a>
-		      {% else %}
-                      {{item.timeslot.get_html_location}}
-		      {% endif %}
-		      {% with item.timeslot.location.floorplan as floor %}
-		      {% if item.timeslot.location.floorplan %}
-                      <span class="hidden-xs">
-			<a href="{% url 'ietf.meeting.views.floor_plan' num=schedule.meeting.number %}#{{floor.name|xslugify}}"
-			  class="pull-right" title="{{floor.name}}"><span class="label label-blank label-wide">{{floor.short}}</span></a>
-                      </span>
-		      {% endif %}
-		      {% endwith %}
-	            {% endif %}
-	          </td>
-                  <td>
-		    {% if item.session.agenda %}
-		      <a href="{{ item.session.agenda.get_href }}">
-			{{item.timeslot.name}}
-		      </a>
-		    {% else %}
-		      {{item.timeslot.name}}
-		    {% endif %}
-
-                    {% if item.session.current_status == 'canceled' %}
-		      <span class="label label-danger pull-right">CANCELLED</span>
-                    {% else %}
-                      <div class="pull-right padded-left">
-                        {% if item.timeslot.type.slug == 'other' %}
-                          {% if item.session.agenda or item.session.remote_instructions or item.session.agenda_note %}
-                            {% include "meeting/session_buttons_include.html" with show_agenda=True item=item schedule=schedule %}
-                          {% else %}
-                            {% for slide in item.session.slides %}
-                              <a href="{{slide.get_href}}">{{ slide.title|clean_whitespace }}</a>
-                              <br>
-                            {% endfor %}
-                          {% endif %}
+                      </td>
+                      <td colspan="3">
+                        <span class="hidden-sm hidden-md hidden-lg">
+                           {% include "meeting/timeslot_start_end.html" %}
+                        </span>
+                        {% location_anchor item.timeslot %}
+                          {{ item.timeslot.get_html_location }}
+                        {% end_location_anchor %}
+                        {% if item.timeslot.show_location and item.timeslot.get_html_location %}
+                          {% with item.timeslot.location.floorplan as floor %}
+                            {% if item.timeslot.location.floorplan %}
+                              <span class="hidden-xs">
+                                <a href="{% url 'ietf.meeting.views.floor_plan' num=schedule.meeting.number %}#{{floor.name|xslugify}}"
+                                   class="pull-right" title="{{floor.name}}"><span class="label label-blank label-wide">{{floor.short}}</span></a>
+                              </span>
+                            {% endif %}
+                          {% endwith %}
                         {% endif %}
-                      </div>
-		    {% endif %}
-		  </td>
-                  <td class="rightmarker"></td>
-                </tr>
-            {% endif %}
+                      </td>
+                      <td>
+                          {% agenda_anchor item.session %}
+                            {% assignment_display_name item %}
+                          {% end_agenda_anchor %}
+
+                          {% if item.session.current_status == 'canceled' %}
+                              <span class="label label-danger pull-right">CANCELLED</span>
+                          {% else %}
+                              <div class="pull-right padded-left">
+                                  {% if item.slot_type.slug == 'other' %}
+                                      {% if item.session.agenda or item.session.remote_instructions or item.session.agenda_note %}
+                                          {% include "meeting/session_buttons_include.html" with show_agenda=True item=item schedule=schedule %}
+                                      {% else %}
+                                          {% for slide in item.session.slides %}
+                                              <a href="{{slide.get_href}}">{{ slide.title|clean_whitespace }}</a>
+                                              <br>
+                                          {% endfor %}
+                                      {% endif %}
+                                  {% endif %}
+                              </div>
+                          {% endif %}
+                      </td>
+                      <td class="rightmarker"></td>
+                  </tr>
+
+              {% elif item|is_regular_agenda_item or item|is_plenary_agenda_item %}
+
+                {% if item|is_regular_agenda_item %}
+                  {% ifchanged %}
+                      <tr class="info session-label-row"
+                          data-slot-start-ts="{{item.start_timestamp}}"
+                          data-slot-end-ts="{{item.end_timestamp}}">
+                          <td class="leftmarker"></td>
+                          <th class="text-nowrap text-right">
+                        <span class="hidden-xs">
+                           {% include "meeting/timeslot_start_end.html" %}
+                        </span>
+                          </th>
+                          <th colspan="4">
+                        <span class="hidden-sm hidden-md hidden-lg">
+                           {% include "meeting/timeslot_start_end.html" %}
+                        </span>
+                              {{ item.timeslot.time|date:"l"}}
+                              {{item.timeslot.name|capfirst_allcaps}}
+                          </th>
+                          <td class="rightmarker"></td>
+                      </tr>
+                  {% endifchanged %}
+                {% endif %}
 
-            {% if item.timeslot.type_id == 'regular' or item.timeslot.type.slug == 'plenary' %}
               {% if item.session.historic_group %}
-                <tr id="row-{{item.slug}}" 
-                    {% if item.timeslot.type.slug == 'plenary' %}class="{{item.timeslot.type.slug}}danger"{% endif %}
+                <tr id="row-{{item.slug}}"
+                    {% if item.slot_type.slug == 'plenary' %}class="{{item.slot_type.slug}}danger"{% endif %}
                     data-filter-keywords="{{ item.filter_keywords|join:',' }}"
                     data-slot-start-ts="{{item.start_timestamp}}"
                     data-slot-end-ts="{{item.end_timestamp}}">
                   <td class="leftmarker"></td>
-		  {% if item.timeslot.type.slug == 'plenary' %}
+		  {% if item.slot_type.slug == 'plenary' %}
 	            <th class="text-nowrap text-right">
                       <span class="hidden-xs">
                          {% include "meeting/timeslot_start_end.html" %}
                       </span>
 		    </th>
-		    <td colspan="3">
+        <td colspan="3">
                       <span class="hidden-sm hidden-md hidden-lg">
                          {% include "meeting/timeslot_start_end.html" %}
                       </span>
-		      {% if item.timeslot.show_location and item.timeslot.get_html_location %}
-			{% if schedule.meeting.number|add:"0" < 96 %}
-			<a href="https://tools.ietf.org/agenda/{{schedule.meeting.number}}/venue/?room={{ item.timeslot.get_html_location|xslugify }}">{{item.timeslot.get_html_location}}</a>
-                        {% elif item.timeslot.location.floorplan %}
-			<a href="{% url 'ietf.meeting.views.floor_plan' num=schedule.meeting.number %}?room={{ item.timeslot.get_html_location|xslugify }}">{{item.timeslot.get_html_location}}</a>
-			{% else %}
-                        {{item.timeslot.get_html_location}}
-			{% endif %}
-		      {% endif %}
-		    </td>
+          {% location_anchor item.timeslot %}
+            {{ item.timeslot.get_html_location }}
+          {% end_location_anchor %}
+        </td>
 
 		  {% else %}
 		    <td>
@@ -269,15 +255,9 @@
 		      {% endwith %}
 		    </td>
                     <td>
-                      {% if item.timeslot.show_location and item.timeslot.get_html_location %}
-			{% if schedule.meeting.number|add:"0" < 96 %}
-			<a href="https://tools.ietf.org/agenda/{{schedule.meeting.number}}/venue/?room={{ item.timeslot.get_html_location|xslugify }}">{{item.timeslot.get_html_location}}</a>
-                        {% elif item.timeslot.location.floorplan %}
-			<a href="{% url 'ietf.meeting.views.floor_plan' num=schedule.meeting.number %}?room={{ item.timeslot.get_html_location|xslugify }}">{{item.timeslot.get_html_location}}</a>
-                        {% else %}
-                        {{item.timeslot.get_html_location}}
-			{% endif %}
-                      {% endif %}
+                      {% location_anchor item.timeslot %}
+                        {{ item.timeslot.get_html_location }}
+                      {% end_location_anchor %}
                     </td>
 
 		      <td><span class="hidden-xs">{{item.session.historic_group.historic_parent.acronym}}</span></td>
@@ -292,18 +272,9 @@
                   {% endif %}
 
                   <td>
-                    {% if item.session.agenda %}
-		      <a href="{{ item.session.agenda.get_href }}">
-                    {% endif %}
-                    {% if item.timeslot.type.slug == 'plenary' %}
-                      {{item.timeslot.name}}
-                    {% else %}
-                      {{item.session.historic_group.name}}
-                    {% endif %}
-                    {% if item.session.agenda %}
-                      </a>
-                    {% endif %}
-
+                    {% agenda_anchor item.session %}
+                        {% assignment_display_name item %}
+                    {% end_agenda_anchor %}
                     {% if item.session.current_status == 'canceled' %}
                       <span class="label label-danger pull-right">CANCELLED</span>
                     {% else %}
@@ -453,9 +424,9 @@
                // either have not yet loaded the iframe or we do not support history replacement
                wv_iframe.src = new_url;
            }
-       }       
+       }
    }
-   
+
    function update_view(filter_params) {
        update_agenda_display(filter_params);
        update_weekview(filter_params)
diff --git a/ietf/templates/meeting/agenda.txt b/ietf/templates/meeting/agenda.txt
index ce804b224..489ebbe6a 100644
--- a/ietf/templates/meeting/agenda.txt
+++ b/ietf/templates/meeting/agenda.txt
@@ -14,17 +14,17 @@
 
 
 {{ item.timeslot.time|date:"l"|upper }}, {{ item.timeslot.time|date:"F j, Y" }}
-{% endifchanged %}{%       if item.timeslot.type.slug == "reg" %}
-{{ item.timeslot.time_desc  }}  {{ item.timeslot.name  }}{% if schedule.meeting.reg_area %} - {{ schedule.meeting.reg_area }}{% endif %}{% endif %}{%          if item.timeslot.type.slug == "plenary" %}
+{% endifchanged %}{%       if item.slot_type.slug == "reg" %}
+{{ item.timeslot.time_desc  }}  {{ item.timeslot.name  }}{% if schedule.meeting.reg_area %} - {{ schedule.meeting.reg_area }}{% endif %}{% endif %}{%          if item.slot_type.slug == "plenary" %}
 {{ item.timeslot.time_desc }}  {{ item.session.name }} - {{ item.timeslot.location.name }}
 
    {{ item.session.agenda_text.strip|indent:"3" }}
-{% endif %}{%          if item.timeslot.type_id == 'regular' %}{% if item.session.historic_group  %}{% ifchanged %}
+{% endif %}{%          if item.slot_type.slug == 'regular' %}{% if item.session.historic_group  %}{% ifchanged %}
 
 {{ item.timeslot.time_desc }}  {{ item.timeslot.name }}
 {% endifchanged %}{{ item.timeslot.location.name|ljust:14 }}  	{{ item.session.historic_group.historic_parent.acronym|upper|ljust:4 }}	{{ item.session.historic_group.acronym|ljust:10 }}  	{{ item.session.historic_group.name }} {% if item.session.historic_group.state_id == "bof" %}BOF{% elif item.session.historic_group.type_id == "wg" %}WG{% endif %}{% if item.session.agenda_note %} - {{ item.session.agenda_note }}{% endif %}{% if item.session.current_status == 'canceled' %}  *** CANCELLED ***{% elif item.session.current_status == 'resched' %} *** RESCHEDULED{% if item.session.rescheduled_to %} TO {{ item.session.rescheduled_to.time|date:"l G:i"|upper }}-{{ item.session.rescheduled_to.end_time|date:"G:i" }}{% endif %} ***{% endif %}
-{% endif %}{% endif %}{% if item.timeslot.type.slug == "break" %}
-{{ item.timeslot.time_desc }}  {{ item.timeslot.name }}{% if schedule.meeting.break_area and item.timeslot.show_location %} - {{ schedule.meeting.break_area }}{% endif %}{% endif %}{%            if item.timeslot.type.slug == "other" %}
+{% endif %}{% endif %}{% if item.slot_type.slug == "break" %}
+{{ item.timeslot.time_desc }}  {{ item.timeslot.name }}{% if schedule.meeting.break_area and item.timeslot.show_location %} - {{ schedule.meeting.break_area }}{% endif %}{% endif %}{%            if item.slot_type.slug == "other" %}
 {{ item.timeslot.time_desc }}  {{ item.timeslot.name }} - {{ item.timeslot.location.name }}{% endif %}{% endfor %}
 
 ====================================================================
diff --git a/ietf/templates/meeting/agenda_by_room.html b/ietf/templates/meeting/agenda_by_room.html
index 2e0a68bd7..8f292fda0 100644
--- a/ietf/templates/meeting/agenda_by_room.html
+++ b/ietf/templates/meeting/agenda_by_room.html
@@ -29,7 +29,7 @@ ul.sessionlist { list-style:none; padding-left:2em; margin-bottom:10px;}
 	    <li class="roomlistentry"><h3>{{room.grouper|default:"Location Unavailable"}}</h3>
 	      <ul class="sessionlist">
 		{% for ss in room.list %}
-                  <li class="sessionlistentry type-{{ss.timeslot.type_id}} {% if ss.schedule_id != meeting.schedule_id %}from-base-schedule{% endif %}">{{ss.timeslot.time|date:"H:i"}}-{{ss.timeslot.end_time|date:"H:i"}} {{ss.session.short_name}}</li>
+                  <li class="sessionlistentry type-{{ss.slot_type.slug}} {% if ss.schedule_id != meeting.schedule_id %}from-base-schedule{% endif %}">{{ss.timeslot.time|date:"H:i"}}-{{ss.timeslot.end_time|date:"H:i"}} {{ss.session.short_name}}</li>
 		{% endfor %}
 	      </ul>
 	    </li>
diff --git a/ietf/templates/meeting/agenda_filter.html b/ietf/templates/meeting/agenda_filter.html
index cd91e7c1f..dff07a03a 100644
--- a/ietf/templates/meeting/agenda_filter.html
+++ b/ietf/templates/meeting/agenda_filter.html
@@ -61,12 +61,12 @@ Optional parameters:
                                     {% for fc in filter_categories %}
                                         {% if not forloop.first %} <td></td> {% endif %}
                                         {% for p in fc %}
-                                            <td class="view {{ p.keyword }}">
+                                            <td class="view{% if p.keyword %} {{ p.keyword }}{% endif %}">
                                                 <div class="btn-group-vertical btn-block">
-                                                    {% for button in p.children|dictsort:"label" %}
+                                                    {% for button in p.children %}
                                                         <div class="btn-group btn-group-xs btn-group-justified">
                                                             <button class="btn btn-default pickview {{ button.keyword }}"
-                                                                    {% if p.keyword or button.is_bof %}data-filter-keywords="{% if p.keyword %}{{ p.keyword }}{% if button.is_bof %},{% endif %}{% endif %}{% if button.is_bof %}bof{% endif %}"{% endif %}
+                                                                    {% if button.toggled_by %}data-filter-keywords="{{ button.toggled_by|join:"," }}"{% endif %}
                                                                     data-filter-item="{{ button.keyword }}">
                                                                 {% if button.is_bof %}
                                                                     <i>{{ button.label }}</i>
diff --git a/ietf/templates/meeting/create_timeslot.html b/ietf/templates/meeting/create_timeslot.html
new file mode 100755
index 000000000..c3ff73058
--- /dev/null
+++ b/ietf/templates/meeting/create_timeslot.html
@@ -0,0 +1,27 @@
+{% extends "base.html" %}
+{# Copyright The IETF Trust 2021, All Rights Reserved #}
+{% load origin %}
+{% load bootstrap3 %}
+
+{% block pagehead %}
+  {{ form.media.css }}
+{% endblock %}
+
+{% block title %}Create timeslot for {{meeting}}{% endblock %}
+
+{% block content %}
+  {% origin %}
+  <h1>Create timeslot for {{meeting}}</h1>
+  <form method="post">
+    {% csrf_token %}
+    {% bootstrap_form form %}
+    {% buttons %}
+      <button type="submit" class="btn btn-primary">Save</button>
+      <a class="btn btn-default" href="{% url 'ietf.meeting.views.edit_timeslots' num=meeting.number %}">Cancel</a>
+    {% endbuttons %}
+  </form>
+{% endblock %}
+
+{% block js %}
+  {{ form.media.js }}
+{% endblock %}
\ No newline at end of file
diff --git a/ietf/templates/meeting/edit_timeslot.html b/ietf/templates/meeting/edit_timeslot.html
new file mode 100644
index 000000000..1bce120f7
--- /dev/null
+++ b/ietf/templates/meeting/edit_timeslot.html
@@ -0,0 +1,35 @@
+{% extends "base.html" %}
+{# Copyright The IETF Trust 2021, All Rights Reserved #}
+{% load origin %}
+{% load bootstrap3 %}
+
+{% block pagehead %}
+  {{ form.media.css }}
+{% endblock %}
+
+{% block title %}Edit timeslot "{{ timeslot.name }}" for {{ timeslot.meeting }}{% endblock %}
+
+{% block content %}
+  {% origin %}
+  <h1>Edit timeslot "{{ timeslot.name }}" for {{ timeslot.meeting }}</h1>
+  {% if sessions %}
+    <div class="alert alert-warning">
+      This timeslot currently has the following sessions assigned to it:
+      {% for s in sessions %}
+        <div>{{s}}</div>
+      {% endfor %}
+    </div>
+  {% endif %}
+  <form method="post">
+    {% csrf_token %}
+    {% bootstrap_form form %}
+    {% buttons %}
+      <button type="submit" class="btn btn-primary">Save</button>
+      <a class="btn btn-default" href="{% url 'ietf.meeting.views.edit_timeslots' num=timeslot.meeting.number %}">Cancel</a>
+    {% endbuttons %}
+  </form>
+{% endblock %}
+
+{% block js %}
+  {{ form.media.js }}
+{% endblock %}
\ No newline at end of file
diff --git a/ietf/templates/meeting/interim_session_buttons.html b/ietf/templates/meeting/interim_session_buttons.html
index 417c587b7..15ca3bafc 100644
--- a/ietf/templates/meeting/interim_session_buttons.html
+++ b/ietf/templates/meeting/interim_session_buttons.html
@@ -16,13 +16,13 @@
     {% endif %}
       <!-- etherpad -->
     {% if use_codimd %}
-      {% if item.timeslot.type.slug == 'plenary' %}
+      {% if item.slot_type.slug == 'plenary' %}
         <a class="" href="https://codimd.ietf.org/notes-ietf-{{ meeting.number }}-plenary title="Etherpad for note-takers"><span class="fa fa-fw fa-edit"></span></a>
       {% else %}
         <a class="" href="https://codimd.ietf.org/notes-ietf-{{ meeting.number }}-{{acronym}}" title="Etherpad for note-takers"><span class="fa fa-fw fa-edit"></span></a>
       {% endif %}
     {% else %}
-      {% if item.timeslot.type.slug == 'plenary' %}
+      {% if item.slot_type.slug == 'plenary' %}
         <a class="" href="https://etherpad.ietf.org:9009/p/notes-ietf-{{ meeting.number }}-plenary?useMonospaceFont=true" title="Etherpad for note-takers"><span class="fa fa-fw fa-edit"></span></a>
       {% else %}
         <a class="" href="https://etherpad.ietf.org:9009/p/notes-ietf-{{ meeting.number }}-{{acronym}}?useMonospaceFont=true" title="Etherpad for note-takers"><span class="fa fa-fw fa-edit"></span></a>
diff --git a/ietf/templates/meeting/room-view.html b/ietf/templates/meeting/room-view.html
index 74244e944..5baf3cf66 100644
--- a/ietf/templates/meeting/room-view.html
+++ b/ietf/templates/meeting/room-view.html
@@ -24,7 +24,7 @@
   {% for ss in assignments %}
      if (room_names.indexOf("{{ss.timeslot.get_hidden_location}}") >= 0 )
      {
-         items.push({room_index:room_names.indexOf("{{ss.timeslot.get_hidden_location}}"),day:{{ss.day}}, delta_from_beginning:{{ss.delta_from_beginning}},time:"{{ss.timeslot.time|date:"Hi"}}-{{ss.timeslot.end_time|date:"Hi"}}", verbose_time:"{{ss.timeslot.time|date:"D M d Hi"}}-{{ss.timeslot.end_time|date:"Hi"}}",duration:{{ss.timeslot.duration.total_seconds}}, type:"{{ss.timeslot.type}}", {% if ss.session.name %}name:"{{ss.session.name|escapejs}}",{% if ss.session.group.acronym %} wg:"{{ss.session.group.acronym}}",{%endif%}{% else %}{% if ss.timeslot.type.name == "Break" %}name:"{{ss.timeslot.name|escapejs}}", area:"break", wg:"break",{% elif ss.timeslot.type_id == "unavail" %}name:"Unavailable",{% else %}name:"{{ss.session.group.name|escapejs}}{%if ss.session.group.state.name == "BOF"%} BOF{%endif%}",wg:"{{ss.session.group.acronym}}",state:"{{ss.session.group.state}}",area:"{{ss.session.group.parent.acronym}}",{% endif %}{% endif %} dayname:"{{ ss.timeslot.time|date:"l"|upper }}, {{ ss.timeslot.time|date:"F j, Y" }}"{% if ss.session.agenda %}, agenda:"{{ss.session.agenda.get_href}}"{% endif %}, from_base_schedule: {% if ss.schedule_id != meeting.schedule_id %}true{% else %}false{% endif %} });
+         items.push({room_index:room_names.indexOf("{{ss.timeslot.get_hidden_location}}"),day:{{ss.day}}, delta_from_beginning:{{ss.delta_from_beginning}},time:"{{ss.timeslot.time|date:"Hi"}}-{{ss.timeslot.end_time|date:"Hi"}}", verbose_time:"{{ss.timeslot.time|date:"D M d Hi"}}-{{ss.timeslot.end_time|date:"Hi"}}",duration:{{ss.timeslot.duration.total_seconds}}, type:"{{ss.slot_type}}", {% if ss.session.name %}name:"{{ss.session.name|escapejs}}",{% if ss.session.group.acronym %} wg:"{{ss.session.group.acronym}}",{%endif%}{% else %}{% if ss.slot_type.name == "Break" %}name:"{{ss.timeslot.name|escapejs}}", area:"break", wg:"break",{% elif ss.slot_type.slug == "unavail" %}name:"Unavailable",{% else %}name:"{{ss.session.group.name|escapejs}}{%if ss.session.group.state.name == "BOF"%} BOF{%endif%}",wg:"{{ss.session.group.acronym}}",state:"{{ss.session.group.state}}",area:"{{ss.session.group.parent.acronym}}",{% endif %}{% endif %} dayname:"{{ ss.timeslot.time|date:"l"|upper }}, {{ ss.timeslot.time|date:"F j, Y" }}"{% if ss.session.agenda %}, agenda:"{{ss.session.agenda.get_href}}"{% endif %}, from_base_schedule: {% if ss.schedule_id != meeting.schedule_id %}true{% else %}false{% endif %} });
      }
   {% endfor %}
   {% endautoescape %}
diff --git a/ietf/templates/meeting/schedule_list.html b/ietf/templates/meeting/schedule_list.html
index 9ac4f0dd9..a51305431 100644
--- a/ietf/templates/meeting/schedule_list.html
+++ b/ietf/templates/meeting/schedule_list.html
@@ -10,6 +10,9 @@
   <h1>{% block title %}Possible Meeting Agendas for IETF {{ meeting.number }}{% endblock %}</h1>
 
   <div>
+    {% if can_edit_timeslots %}
+      <p><a href="{% url "ietf.meeting.views.edit_timeslots" num=meeting.number %}">Edit timeslots and room availability</a></p>
+    {% endif %}
     {% for schedules, own, label in schedule_groups %}
       <div class="panel panel-default">
         <div class="panel-heading">
diff --git a/ietf/templates/meeting/session_buttons_include.html b/ietf/templates/meeting/session_buttons_include.html
index 7319ad1f1..15d7ef675 100644
--- a/ietf/templates/meeting/session_buttons_include.html
+++ b/ietf/templates/meeting/session_buttons_include.html
@@ -2,8 +2,10 @@
 {% load origin %}
 {% load static %}
 {% load textfilters %}
+{% load ietf_filters %}
 {% origin %}
 
+{% if item|should_show_agenda_session_buttons %}
 {% with slug=item.slug %}{% with session=item.session %}{% with timeslot=item.timeslot %}{% with meeting=schedule.meeting %}
 <span id="session-buttons-{{session.pk}}" class="text-nowrap">
   {% with acronym=session.historic_group.acronym %}
@@ -117,4 +119,5 @@
   {% endif %}
   {% endwith %}
 </span>
-{% endwith %}{% endwith %}{% endwith %}{% endwith %}
\ No newline at end of file
+{% endwith %}{% endwith %}{% endwith %}{% endwith %}
+{% endif %}
\ No newline at end of file
diff --git a/ietf/templates/meeting/session_details_form.html b/ietf/templates/meeting/session_details_form.html
new file mode 100644
index 000000000..cb69d8858
--- /dev/null
+++ b/ietf/templates/meeting/session_details_form.html
@@ -0,0 +1,24 @@
+{# Copyright The IETF Trust 2007-2020, All Rights Reserved #}
+{% if hidden %}{{ form.name.as_hidden }}{{ form.purpose.as_hidden }}{{ form.type.as_hidden }}{{ form.requested_duration.as_hidden }}
+{% else %}<div class="session-details-form" data-prefix="{{ form.prefix }}">
+  {{ form.id.as_hidden }}
+  {{ form.DELETE.as_hidden }}
+  <table>
+    <tr>
+      <th>{{ form.name.label_tag }}</th>
+      <td>{{ form.name }}{{ form.purpose.errors }}</td>
+    </tr>
+    <tr>
+      <th>{{ form.purpose.label_tag }}</th>
+      <td>
+        {{ form.purpose }} {{ form.type }}
+        {{ form.purpose.errors }}{{ form.type.errors }}
+      </td>
+    <tr>
+      <th>{{ form.requested_duration.label_tag }}</th>
+      <td>{{ form.requested_duration }}{{ form.requested_duration.errors }}</td>
+    </tr>
+    </tr>
+  </table>
+</div>
+{% endif %}
\ No newline at end of file
diff --git a/ietf/templates/meeting/timeslot_edit.html b/ietf/templates/meeting/timeslot_edit.html
index d75ad70d3..dbdfb02a1 100644
--- a/ietf/templates/meeting/timeslot_edit.html
+++ b/ietf/templates/meeting/timeslot_edit.html
@@ -3,55 +3,410 @@
 {% load origin %}
 {% load agenda_custom_tags %}
 
-{% block title %}IETF {{ meeting.number }} Meeting Agenda: Timeslot/Room Availability{% endblock %}
+{% block title %}IETF {{ meeting.number }} Meeting Agenda: Timeslots and Room Availability{% endblock %}
 
 {% block morecss %}
-.tstable { width: 100%;}
-.tstable th { white-space: nowrap;}
-.tstable td { white-space: nowrap;}
-.capacity { font-size:80%; font-weight: normal;}
-
-.tstable .tstype_unavail {background-color:#666;}
+  .tstable { width: 100%;}
+  .tstable th { white-space: nowrap;}
+  .tstable td { white-space: nowrap;}
+  .capacity { font-size:80%; font-weight: normal;}
 {% endblock %}
 
 {% block content %}
   {% origin %}
 
+  <p class="pull-right">
+    <a href="{% url "ietf.meeting.views.create_timeslot" num=meeting.number %}">New timeslot</a>
+    &middot;
+    {% if meeting.schedule %}
+      <a href="{% url "ietf.secr.meetings.views.rooms" meeting_id=meeting.number schedule_name=meeting.schedule.name %}">Edit rooms</a>
+    {% else %} {# secr app room view requires a schedule - show something for now (should try to avoid this possibility) #}
+      <span title="Must create meeting schedule to edit rooms">Edit rooms</span>
+    {% endif %}
+  </p>
 
-<table class="tstable table table-striped table-compact table-bordered">
-  <thead>
-  <tr>
-  <th></th>
-  {% for day in time_slices %}
-    <th colspan="{{date_slices|colWidth:day}}"> 
-      {{day|date:'D'}}&nbsp;({{day}})
-    </th>
-  {% endfor %}
-  </tr>
-  <tr>
-    <th></th>
-    {% for day in time_slices %}
-	  {% for slot in slot_slices|lookup:day %}
-	      <th>
-                {{slot.time|date:'Hi'}}-{{slot.end_time|date:'Hi'}}
+  <p> IETF {{ meeting.number }} Meeting Agenda: Timeslots and Room Availability</p>
+  <div class="timeslot-edit">
+  {% if rooms|length == 0 %}
+    <p>No rooms exist for this meeting yet.</p>
+    {% if meeting.schedule %}
+      <a href="{% url "ietf.secr.meetings.views.rooms" meeting_id=meeting.number schedule_name=meeting.schedule.name %}">Create rooms</a>
+    {% else %}{# provide as helpful a link we can if we don't have an official schedule #}
+      <a href="{% url "ietf.secr.meetings.views.view" meeting_id=meeting.number %}">Create rooms through the secr app</a>
+    {% endif %}
+  {% else %}
+    <table id="timeslot-table" class="tstable table table-striped table-compact table-bordered">
+      {% with have_no_timeslots=time_slices|length_is:0 %}
+      <thead>
+      <tr>
+        {% if have_no_timeslots %}
+          <th></th>
+          <th></th>
+        {% else %}
+          <th></th>
+          {% for day in time_slices %}
+            <th class="day-label"
+                colspan="{{date_slices|colWidth:day}}">
+              {{day|date:'D'}}&nbsp;({{day}})
+              <span class="fa fa-trash delete-button"
+                    title="Delete all on this day"
+                    data-delete-scope="day"
+                    data-date-id="{{ day.isoformat }}">
+              </span>
+            </th>
+          {% endfor %}
+        {% endif %}
+      </tr>
+      <tr>
+        {% if have_no_timeslots %}
+          <th></th>
+          <th></th>
+        {% else %}
+          <th></th>
+          {% for day in time_slices %}
+            {% for slot in slot_slices|lookup:day %}
+              <th class="time-label">
+                {{slot.time|date:'H:i'}}-{{slot.end_time|date:'H:i'}}
+                <span class="fa fa-trash delete-button"
+                      data-delete-scope="column"
+                      data-date-id="{{ day.isoformat }}"
+                      data-col-id="{{ day.isoformat }}T{{slot.time|date:'H:i'}}-{{slot.end_time|date:'H:i'}}"
+                      title="Delete all in this column">
+                </span>
               </th>
-	  {% endfor %}
-    {% endfor %}
-  </tr>
-  </thead>
+            {% endfor %}
+          {% endfor %}
+        {% endif %}
+      </tr>
+      </thead>
 
-  {% for room in rooms %}
-  <tr>
-    <th>{{room.name}}<span class='capacity'>{% if room.capacity %} ({{room.capacity}}){% endif %}</th>
-    {% for day in time_slices %}
-      {% for slice in date_slices|lookup:day %}
-        {% with ts=ts_list.popleft %}
-          <td{% if ts %} class="tstype_{{ts.type.slug}}"{% endif %}>{% if ts %}<a href="{% url 'ietf.meeting.views.edit_timeslot_type' num=meeting.number slot_id=ts.id %}">{{ts.type.slug}}</a>{% endif %}</td>
-        {% endwith %}
+      <tbody>
+      {% for room in rooms %}
+        <tr>
+          <th><span class="room-heading">{{room.name}}{% if room.capacity %} <span class='capacity'>({{room.capacity}})</span>{% endif %}</span></th>
+          {% if have_no_timeslots and forloop.first %}
+            <td rowspan="{{ rooms|length }}">
+              <p>No timeslots exist for this meeting yet.</p>
+              <a href="{% url "ietf.meeting.views.create_timeslot" num=meeting.number %}">Create a timeslot.</a>
+            </td>
+          {% else %}
+            {% for day in time_slices %}
+              {% for slice in date_slices|lookup:day %}{% with cell_ts=ts_list.popleft %}
+                <td class="tscell {% if cell_ts|length > 1 %}timeslot-collision {% endif %}{% for ts in cell_ts %}tstype_{{ ts.type.slug }} {% endfor %}">
+                {% for ts in cell_ts %}
+                  {% include 'meeting/timeslot_edit_timeslot.html' with ts=ts in_use=ts_with_any_assignments in_official_use=ts_with_official_assignments only %}
+                {% endfor %}
+              {% endwith %}{% endfor %}
+              </td>
+            {% endfor %}
+          {% endif %}
+        </tr>
       {% endfor %}
-    {% endfor %}
-  </tr>
-  {% endfor %}
-</table>
+      </tbody>
+      {% endwith %}
+    </table>
+  {% endif %}
+
+  {# Modal to confirm timeslot deletion #}
+  <div id="delete-modal" class="modal" tabindex="-1" role="dialog">
+    <div class="modal-dialog" role="document">
+      <div class="modal-content">
+        <div class="modal-header">
+          <button type="button" class="close" data-dismiss="modal">
+            <span aria-hidden="true">&times;</span>
+            <span class="sr-only">Close</span>
+          </button>
+          <h4 class="modal-title">Confirm Delete</h4>
+        </div>
+
+        <div class="modal-body">
+          <p>
+            <span class="ts-singular">The following timeslot</span>
+            <span class="ts-plural"><span class="ts-count"></span> timeslots</span>
+            will be deleted:
+          </p>
+          <dl class="dl-horizontal">
+            <dt>Name</dt><dd><span class="ts-name"></span></dd>
+            <dt>Date</dt><dd><span class="ts-date"></span></dd>
+            <dt>Time</dt><dd><span class="ts-time"></span></dd>
+            <dt>Location</dt><dd><span class="ts-location"></span></dd>
+          </dl>
+          <p class="unofficial-use-warning">
+            The official schedule will not be affected, but sessions in
+            unofficial schedules currently assigned to
+            <span class="ts-singular">this timeslot</span>
+            <span class="ts-plural">any of these timeslots</span>
+            will become unassigned.
+          </p>
+          <p class="official-use-warning">
+            The official schedule will be affected.
+            Sessions currently assigned to
+            <span class="ts-singular">this timeslot</span>
+            <span class="ts-plural">any of these timeslots</span>
+            will become unassigned.
+          </p>
+          <p>
+            <span class="ts-singular">Are you sure you want to delete this timeslot?</span>
+            <span class="ts-plural">Are you sure you want to delete these <span class="ts-count"></span> timeslots?</span>
+          </p>
+        </div>
+
+        <div class="modal-footer">
+          <button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
+          <button type="button" id="confirm-delete-button" class="btn btn-primary">Delete</button>
+        </div>
+      </div>
+    </div>
+  </div>
+  </div>
 
 {% endblock %}
+
+{% block js %}
+  <script type="text/javascript">
+  // create a namespace for local JS
+  timeslotEdit = (function() {
+    let deleteModal;
+    let timeslotTableBody = document.querySelector('#timeslot-table tbody');
+
+    function initializeDeleteModal() {
+      deleteModal = jQuery('#delete-modal');
+      deleteModal.eltsToDelete = null; // PK of TimeSlot that modal 'Delete' button should delete
+      let spans = deleteModal.find('span');
+      deleteModal.elts = {
+        unofficialUseWarning: deleteModal.find('.unofficial-use-warning'),
+        officialUseWarning: deleteModal.find('.official-use-warning'),
+        timeslotNameSpans: spans.filter('.ts-name'),
+        timeslotDateSpans: spans.filter('.ts-date'),
+        timeslotTimeSpans: spans.filter('.ts-time'),
+        timeslotLocSpans: spans.filter('.ts-location'),
+        timeslotCountSpans: spans.filter('.ts-count'),
+        pluralSpans: spans.filter('.ts-plural'),
+        singularSpans: spans.filter('.ts-singular')
+      };
+
+      document.getElementById('confirm-delete-button').addEventListener(
+        'click',
+        () => timeslotEdit.handleDeleteButtonClick()
+      );
+
+      function uniqueArray(a) {
+        let s = new Set();
+        a.forEach(item => s.add(item));
+        return Array.from(s);
+      }
+      deleteModal.openModal = function (eltsToDelete) {
+        let eltArray = Array.from(eltsToDelete); // make sure this is an array
+
+        if (eltArray.length > 1) {
+          deleteModal.elts.pluralSpans.show();
+          deleteModal.elts.singularSpans.hide();
+        } else {
+          deleteModal.elts.pluralSpans.hide();
+          deleteModal.elts.singularSpans.show();
+        }
+        deleteModal.elts.timeslotCountSpans.text(String(eltArray.length));
+
+        let names = uniqueArray(eltArray.map(elt => elt.dataset.timeslotName));
+        if (names.length === 1) {
+          names = names[0];
+        } else {
+          names.sort();
+          names = names.join(', ');
+        }
+        deleteModal.elts.timeslotNameSpans.text(names);
+
+        let dates = uniqueArray(eltArray.map(elt => elt.dataset.timeslotDate));
+        if (dates.length === 1) {
+          dates = dates[0];
+        } else {
+          dates = 'Multiple';
+        }
+        deleteModal.elts.timeslotDateSpans.text(dates);
+
+        let times = uniqueArray(eltArray.map(elt => elt.dataset.timeslotTime));
+        if (times.length === 1) {
+          times = times[0];
+        } else {
+          times = 'Multiple';
+        }
+        deleteModal.elts.timeslotTimeSpans.text(times);
+
+        let locs = uniqueArray(eltArray.map(elt => elt.dataset.timeslotLocation));
+        if (locs.length === 1) {
+          locs = locs[0];
+        } else {
+          locs = 'Multiple';
+        }
+        deleteModal.elts.timeslotLocSpans.text(locs);
+
+        // Check whether any of the elts are used in official / unofficial schedules
+        let unofficialUse = eltArray.some(elt => elt.dataset.unofficialUse === 'true');
+        let officialUse = eltArray.some(elt => elt.dataset.officialUse === 'true');
+        deleteModal.elts.unofficialUseWarning.hide();
+        deleteModal.elts.officialUseWarning.hide();
+        if (officialUse) {
+          deleteModal.elts.officialUseWarning.show();
+        } else if (unofficialUse) {
+          deleteModal.elts.unofficialUseWarning.show();
+        }
+
+        deleteModal.eltsToDelete  = eltsToDelete;
+        deleteModal.modal('show');
+      }
+
+      /**
+       * Handle deleting a single timeslot
+       *
+       * clicked arg is the clicked element, which must be a child of the timeslot element
+       */
+      function deleteSingleTimeSlot(clicked) {
+        deleteModal.openModal([clicked.closest('.timeslot')]);
+      }
+
+      /**
+       * Handle deleting an entire day worth of timeslots
+       *
+       * clicked arg is the clicked element, which must be a child of the day header element
+       */
+      function deleteDay(clicked) {
+        // Find all timeslots for this day
+        let dateId = clicked.dataset.dateId;
+        let timeslots = timeslotTableBody.querySelectorAll(
+          ':scope .timeslot[data-date-id="' + dateId + '"]' // :scope prevents picking up results outside table body
+        );
+        if (timeslots.length > 0) {
+          deleteModal.openModal(timeslots);
+        }
+      }
+
+      /**
+       * Handle deleting an entire column worth of timeslots
+       *
+       * clicked arg is the clicked element, which must be a child of the column header element
+       */
+      function deleteColumn(clicked) {
+        let colId = clicked.dataset.colId;
+        let timeslots = timeslotTableBody.querySelectorAll(
+          ':scope .timeslot[data-col-id="' + colId + '"]' // :scope prevents picking up results outside table body
+        );
+        if (timeslots.length > 0) {
+          deleteModal.openModal(timeslots);
+        }
+      }
+
+      /**
+       * Event handler for clicks on the timeslot table
+       *
+       * Handles clicks on all the delete buttons to avoid large numbers of event handlers.
+       */
+      document.getElementById('timeslot-table').addEventListener('click', function(event) {
+        let clicked = event.target; // find out what was clicked
+        if (clicked.dataset.deleteScope) {
+          switch (clicked.dataset.deleteScope) {
+            case 'timeslot':
+              deleteSingleTimeSlot(clicked)
+              break
+
+            case 'column':
+              deleteColumn(clicked)
+              break
+
+            case 'day':
+              deleteDay(clicked)
+              break
+
+            default:
+              throw new Error('Unexpected deleteScope "' + clicked.dataset.deleteScope + '"')
+          }
+        }
+      });
+    }
+
+    // Update timeslot classes when DOM changes
+    function tstableObserveCallback(mutationList) {
+      mutationList.forEach(mutation => {
+        if (mutation.type === 'childList' && mutation.target.classList.contains('tscell')) {
+          const tscell = mutation.target;
+          // mark collisions
+          if (tscell.getElementsByClassName('timeslot').length > 1) {
+            tscell.classList.add('timeslot-collision');
+          } else {
+            tscell.classList.remove('timeslot-collision');
+          }
+
+          // remove timeslot type classes for any removed timeslots
+          mutation.removedNodes.forEach(node => {
+            if (node.classList.contains('timeslot') && node.dataset.timeslotType) {
+              tscell.classList.remove('tstype_' + node.dataset.timeslotType);
+            }
+          });
+
+          // now add timeslot type classes for any remaining timeslots
+          Array.from(tscell.getElementsByClassName('timeslot')).forEach(elt => {
+            if (elt.dataset.timeslotType) {
+              tscell.classList.add('tstype_' + elt.dataset.timeslotType);
+            }
+          });
+        }
+      });
+    }
+
+    function initializeTsTableObserver() {
+      const observer = new MutationObserver(tstableObserveCallback);
+      observer.observe(timeslotTableBody, { childList: true, subtree: true });
+    }
+
+    window.addEventListener('load', function (event) {
+      initializeTsTableObserver();
+      initializeDeleteModal();
+    });
+
+    function removeTimeslotElement(elt) {
+      if (elt.parentNode) {
+        elt.parentNode.removeChild(elt);
+      }
+    }
+
+    function handleDeleteButtonClick() {
+      if (!deleteModal || !deleteModal.eltsToDelete) {
+        return; // do nothing if not yet initialized
+      }
+
+      let timeslotElts = Array.from(deleteModal.eltsToDelete); // make own copy as Array so we have .map()
+      ajaxDeleteTimeSlot(timeslotElts.map(elt => elt.dataset.timeslotPk))
+      .error(function(jqXHR, textStatus) {
+        displayError('Error deleting timeslot: ' + jqXHR.responseText)
+      })
+      .done(function () {timeslotElts.forEach(tse => tse.parentNode.removeChild(tse))})
+      .always(function () {deleteModal.modal('hide')});
+    }
+
+    /**
+     * Make an AJAX request to delete a TimeSlot
+     *
+     * @param pkArray array of PKs of timeslots to delete
+     * @returns jqXHR object corresponding to jQuery ajax request
+     */
+    function ajaxDeleteTimeSlot(pkArray) {
+      return jQuery.ajax({
+        method: 'post',
+        timeout: 5 * 1000,
+        data: {
+          action: 'delete',
+          slot_id: pkArray.join(',')
+        }
+      });
+    }
+
+    function displayError(msg) {
+      window.alert(msg);
+    }
+
+    // export callable methods
+    return {
+      handleDeleteButtonClick: handleDeleteButtonClick,
+    }
+  })();
+  </script>
+{% endblock %}
\ No newline at end of file
diff --git a/ietf/templates/meeting/timeslot_edit_timeslot.html b/ietf/templates/meeting/timeslot_edit_timeslot.html
new file mode 100644
index 000000000..4d8afea4f
--- /dev/null
+++ b/ietf/templates/meeting/timeslot_edit_timeslot.html
@@ -0,0 +1,26 @@
+<div id="timeslot{{ ts.pk }}"
+     class="timeslot {% if ts in in_official_use %}in-official-use{% elif ts in in_use %}in-unofficial-use{% endif %}"
+     data-timeslot-pk="{{ ts.pk }}"
+     data-date-id="{{ ts.time.date.isoformat }}"{# used for identifying day/col contents #}
+     data-col-id="{{ ts.time.date.isoformat }}T{{ ts.time|date:"H:i" }}-{{ ts.end_time|date:"H:i" }}" {# used for identifying column contents #}
+     data-timeslot-name="{{ ts.name }}"
+     data-timeslot-type="{{ ts.type.slug }}"
+     data-timeslot-location="{{ ts.location.name }}"
+     data-timeslot-date="{{ ts.time|date:"l (Y-m-d)" }}"
+     data-timeslot-time="{{ ts.time|date:"H:i" }}-{{ ts.end_time|date:"H:i" }}"
+     data-unofficial-use="{% if ts in in_use and ts not in in_official_use %}true{% else %}false{% endif %}"
+     data-official-use="{% if ts in in_official_use %}true{% else %}false{% endif %}">
+  <div class="ts-name">
+    <span
+      title="{% if ts in in_official_use %}Used in official schedule{% elif ts in in_use %}Used in unofficial schedule{% else %}Unused{% endif %}">
+      {{ ts.name }}
+    </span>
+  </div>
+  <div class="timeslot-buttons">
+    <a href="{% url 'ietf.meeting.views.edit_timeslot' num=ts.meeting.number slot_id=ts.id %}">
+      <span class="fa fa-pencil-square-o"></span>
+    </a>
+    <span class="fa fa-trash delete-button" data-delete-scope="timeslot" title="Delete this timeslot"></span>
+  </div>
+  <div class="ts-type">{{ ts.type }}</div>
+</div>
diff --git a/ietf/utils/fields.py b/ietf/utils/fields.py
index 647ea0722..e5257e5ec 100644
--- a/ietf/utils/fields.py
+++ b/ietf/utils/fields.py
@@ -63,22 +63,67 @@ def yyyymmdd_to_strftime_format(fmt):
             remaining = remaining[1:]
     return res
 
-class DatepickerDateField(forms.DateField):
-    """DateField with some glue for triggering JS Bootstrap datepicker."""
 
-    def __init__(self, date_format, picker_settings={}, *args, **kwargs):
+class DatepickerMedia:
+    """Media definitions needed for Datepicker widgets"""
+    css = dict(all=('bootstrap-datepicker/css/bootstrap-datepicker3.min.css',))
+    js = ('bootstrap-datepicker/js/bootstrap-datepicker.min.js',)
+
+
+class DatepickerDateInput(forms.DateInput):
+    """DateInput that uses the Bootstrap datepicker
+
+    The format must be in the Bootstrap datepicker format (yyyy-mm-dd, e.g.), not the
+    strftime format. The picker_settings argument is a dict of parameters for the datepicker,
+    converting their camelCase names to dash-separated lowercase names and omitting the
+    'data-date' prefix to the key.
+    """
+    Media = DatepickerMedia
+
+    def __init__(self, attrs=None, date_format=None, picker_settings=None):
+        super().__init__(
+            attrs,
+            yyyymmdd_to_strftime_format(date_format),
+        )
+        self.attrs.setdefault('data-provide', 'datepicker')
+        self.attrs.setdefault('data-date-format', date_format)
+        self.attrs.setdefault("data-date-autoclose", "1")
+        self.attrs.setdefault('placeholder', date_format)
+        if picker_settings is not None:
+            for k, v in picker_settings.items():
+                self.attrs['data-date-{}'.format(k)] = v
+
+
+class DatepickerSplitDateTimeWidget(forms.SplitDateTimeWidget):
+    """Split datetime widget using Bootstrap datepicker
+
+    The format must be in the Bootstrap datepicker format (yyyy-mm-dd, e.g.), not the
+    strftime format. The picker_settings argument is a dict of parameters for the datepicker,
+    converting their camelCase names to dash-separated lowercase names and omitting the
+    'data-date' prefix to the key.
+    """
+    Media = DatepickerMedia
+
+    def __init__(self, *, date_format='yyyy-mm-dd', picker_settings=None, **kwargs):
+        date_attrs = kwargs.setdefault('date_attrs', dict())
+        date_attrs.setdefault("data-provide", "datepicker")
+        date_attrs.setdefault("data-date-format", date_format)
+        date_attrs.setdefault("data-date-autoclose", "1")
+        date_attrs.setdefault("placeholder", date_format)
+        if picker_settings is not None:
+            for k, v in picker_settings.items():
+                date_attrs['data-date-{}'.format(k)] = v
+        super().__init__(date_format=yyyymmdd_to_strftime_format(date_format), **kwargs)
+
+
+class DatepickerDateField(forms.DateField):
+    """DateField with some glue for triggering JS Bootstrap datepicker"""
+    def __init__(self, date_format, picker_settings=None, *args, **kwargs):
         strftime_format = yyyymmdd_to_strftime_format(date_format)
         kwargs["input_formats"] = [strftime_format]
-        kwargs["widget"] = forms.DateInput(format=strftime_format)
+        kwargs["widget"] = DatepickerDateInput(dict(placeholder=date_format), date_format, picker_settings)
         super(DatepickerDateField, self).__init__(*args, **kwargs)
 
-        self.widget.attrs["data-provide"] = "datepicker"
-        self.widget.attrs["data-date-format"] = date_format
-        if "placeholder" not in self.widget.attrs:
-            self.widget.attrs["placeholder"] = date_format
-        for k, v in picker_settings.items():
-            self.widget.attrs["data-date-%s" % k] = v
-
 
 # This accepts any ordered combination of labelled days, hours, minutes, seconds
 ext_duration_re = re.compile(
diff --git a/ietf/utils/validators.py b/ietf/utils/validators.py
index 5adb29bc3..7b599b743 100644
--- a/ietf/utils/validators.py
+++ b/ietf/utils/validators.py
@@ -8,8 +8,9 @@ from pyquery import PyQuery
 from urllib.parse import urlparse, urlsplit, urlunsplit
 
 
+from django.apps import apps
 from django.conf import settings
-from django.core.exceptions import ValidationError
+from django.core.exceptions import ObjectDoesNotExist, ValidationError
 from django.core.validators import RegexValidator, URLValidator, EmailValidator, _lazy_re_compile, BaseValidator
 from django.template.defaultfilters import filesizeformat
 from django.utils.deconstruct import deconstructible
@@ -248,3 +249,38 @@ class MaxImageSizeValidator(BaseValidator):
             return x.width, x.height
         except FileNotFoundError:
             return 0, 0  # don't fail if the image is missing
+
+
+@deconstructible
+class JSONForeignKeyListValidator:
+    """Validate that a JSONField is a list of valid foreign key references"""
+    def __init__(self, model_class, field_name='pk'):
+        # model_class can be a class or a string like "app_name.ModelName"
+        self._model = model_class
+        self.field_name = field_name
+
+    @property
+    def model(self):
+        # Lazy model lookup is needed because the validator's __init__() is usually
+        # called while Django is still setting up. It's likely that the model is not
+        # registered at that point, otherwise the caller would not have used the
+        # string form to refer to it.
+        if isinstance(self._model, str):
+            self._model = apps.get_model(self._model)
+        return self._model
+
+    def __call__(self, value):
+        for val in value:
+            try:
+                self.model.objects.get(**{self.field_name: val})
+            except ObjectDoesNotExist:
+                raise ValidationError(
+                    f'No {self.model.__name__} with {self.field_name} == "{val}" exists.'
+                )
+
+    def __eq__(self, other):
+        return (
+                isinstance(other, self.__class__)
+                and (self.model == other.model)
+                and (self.field_name == other.field_name)
+        )