diff --git a/ietf/settings.py b/ietf/settings.py index 3aa45a453c..50e069ff1a 100644 --- a/ietf/settings.py +++ b/ietf/settings.py @@ -231,6 +231,7 @@ AGENDA_CACHE_TIMEOUT_DEFAULT = 8 * 24 * 60 * 60 # 8 days AGENDA_CACHE_TIMEOUT_CURRENT_MEETING = 6 * 60 # 6 minutes + WSGI_APPLICATION = "ietf.wsgi.application" AUTHENTICATION_BACKENDS = ( 'ietf.ietfauth.backends.CaseInsensitiveModelBackend', ) @@ -1270,7 +1271,7 @@ def skip_unreadable_post(record): except ImportError: pass -STATS_NAMES_LIMIT = 25 +STATS_TIMELINE_CACHE_TIMEOUT = 86400 UTILS_MEETING_CONFERENCE_DOMAINS = ['webex.com', 'zoom.us', 'jitsi.org', 'meetecho.com', 'gather.town', ] UTILS_TEST_RANDOM_STATE_FILE = '.factoryboy_random_state' diff --git a/ietf/static/js/meeting_stats.js b/ietf/static/js/meeting_stats.js new file mode 100644 index 0000000000..70b18a0f03 --- /dev/null +++ b/ietf/static/js/meeting_stats.js @@ -0,0 +1,57 @@ +// Copyright The IETF Trust 2026, All Rights Reserved +document.addEventListener('DOMContentLoaded', () => { + // Need to use autocolors plug-in else all slices are gray... + const autocolors = window['chartjs-plugin-autocolors'] + Chart.register(autocolors) + // ── Safely parse JSON data injected from Django view ── + const totalChartData = JSON.parse(document.getElementById('total-chart-data').textContent) + const inPersonChartData = JSON.parse(document.getElementById('in-person-chart-data').textContent) + + function displayChart (id, data) { + const ctx = document.getElementById(id).getContext('2d') + new Chart(ctx, { + type: 'pie', // Change to 'doughnut' for a donut chart + data: data, + options: { + responsive: true, + plugins: { + autocolors: { + mode: 'data' // Required for Pie charts to color individual slices + }, + legend: { + position: 'bottom', + labels: { + padding: 20, + font: { size: 13 }, + color: '#475569', + generateLabels: function (chart) { + const dataset = chart.data.datasets[0] + return chart.data.labels.map((label, i) => ({ + text: `${label}: ${dataset.data[i]}`, + fillStyle: dataset.backgroundColor[i], + hidden: false, + index: i, + })) + } + } + }, + tooltip: { + callbacks: { + label: function (context) { + const label = context.label || '' + const value = context.raw + const total = context.dataset.data.reduce((a, b) => a + b, 0) + const percentage = ((value / total) * 100).toFixed(1) + + return `${label}: ${value} (${percentage}%)` + } + } + } + } + } + }) + } + + displayChart('totalRegistrationChart', totalChartData) + displayChart('inPersonRegistrationChart', inPersonChartData) +}) diff --git a/ietf/static/js/meeting_timeline.js b/ietf/static/js/meeting_timeline.js new file mode 100644 index 0000000000..161cead0ec --- /dev/null +++ b/ietf/static/js/meeting_timeline.js @@ -0,0 +1,84 @@ +// Copyright The IETF Trust 2026, All Rights Reserved +document.addEventListener('DOMContentLoaded', () => { + // ── Safely parse JSON data injected from Django view ── + const totalChartData = JSON.parse(document.getElementById('total-chart-data').textContent) + const inPersonChartData = JSON.parse(document.getElementById('in-person-chart-data').textContent) + const statsType = JSON.parse(document.getElementById('stats-type-data').textContent) + const stackedLines = statsType === 'total' + + function displayChart (id, data) { + const ctx = document.getElementById(id).getContext('2d') + return new Chart(ctx, { + type: 'line', // Change to 'doughnut' for a donut chart + data: data, + options: { + responsive: true, + scales: { + y: { + stacked: stackedLines, + }, + x: { + title: { + display: true, + text: 'IETF Meeting Number', + }, + }, + }, + plugins: { + legend: { + position: 'bottom', + labels: { + usePointStyle: true, + padding: 15, + font: { size: 12 }, + }, + }, + tooltip: { + backgroundColor: 'rgba(0,0,0,0.8)', + titleFont: { size: 14 }, + bodyFont: { size: 13 }, + callbacks: { + title: function (items) { + return `IETF Meeting ${items[0].label}` + }, + label: function (context) { + return ` ${context.dataset.label}: ${context.parsed.y} participants` + } + } + }, + zoom: { + zoom: { + wheel: { enabled: true }, // scroll to zoom + pinch: { enabled: true }, // pinch on mobile + drag: { enabled: true }, // drag to select range + mode: 'xy', // zoom X-axis and Y-axis + }, + pan: { + enabled: true, + mode: 'xy', // pan X-axis and Y-axis + }, + }, + } + } + }) + } + + const totalChart = displayChart('totalRegistrationChart', totalChartData) + if (inPersonChartData !== null) { + inPersonChart = displayChart('inPersonRegistrationChart', inPersonChartData) + } + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + totalChart.resetZoom() + if (inPersonChart !== null) { + inPersonChart.resetZoom() + } + } + }) + document.getElementById('resetButton').addEventListener('click', () => { + totalChart.resetZoom() + if (inPersonChart !== null) { + inPersonChart.resetZoom() + } + }) +}) diff --git a/ietf/stats/views.py b/ietf/stats/views.py index 512764ec0e..d61b673075 100644 --- a/ietf/stats/views.py +++ b/ietf/stats/views.py @@ -9,7 +9,9 @@ import dateutil.relativedelta from collections import defaultdict +from django.conf import settings from django.contrib.auth.decorators import login_required +from django.core.cache import cache from django.http import HttpResponseRedirect from django.shortcuts import render from django.urls import reverse as urlreverse @@ -155,175 +157,192 @@ def canonicalize_affiliation(affiliation): affiliation = prefix return affiliation.title() -def get_affiliation_data_for_meetings(top_n, attendance_type=None): +def get_affiliation_data_for_meetings(attendance_type=None): """Get affiliation participation data for meetings timeline chart. Args: - top_n: Number of top affiliations to include. attendance_type: Optional filter for attendance type (e.g., 'onsite'). Returns: Tuple of (sorted_meetings, datasets) for Chart.js. """ - # Get registration status details - if attendance_type: - registrations = Registration.objects.filter(tickets__attendance_type=attendance_type) - else: - registrations = Registration.objects.all() - registrations = registrations.values('affiliation', 'meeting__number') - - # Count per canonicalized affiliation - organization = dict() - meetings_set = set() - org_totals = defaultdict(int) - data_map = defaultdict(dict) # {org: {meeting: count}} - - for reg in registrations: - meeting = reg['meeting__number'] - meetings_set.add(meeting) - affiliation = canonicalize_affiliation(reg['affiliation']) or "Unspecified" - organization[affiliation] = organization.get(affiliation, 0) + 1 - org_totals[affiliation] = org_totals.get(affiliation, 0) + 1 - data_map[affiliation][meeting] = data_map[affiliation].get(meeting, 0) + 1 - - # ── Step 2: Sort meetings numerically rather than alphabetically ── - sorted_meetings = sorted(meetings_set, key=lambda x: int(x) if x.isdigit() else x) - - # ── Step 3: Get top N countries ── - top_orgs = sorted( - org_totals.keys(), - key=lambda c: org_totals[c], - reverse=True - )[:top_n] - non_top_orgs = org_totals.keys() - top_orgs - other_totals = defaultdict(int) - for m in sorted_meetings: - other_totals[m] = 0 - for c in non_top_orgs: - other_totals[m] += int(data_map[c].get(m, 0)) - - # ── Step 4: Build Chart.js datasets ── - - datasets = [] - for idx, org in enumerate(top_orgs): - color = colors[idx % len(colors)] + cache_key = f'stats:get_affiliation_data_for_meetings:{attendance_type}' + sorted_meetings, datasets = cache.get(cache_key, (None, None)) + if (sorted_meetings, datasets) == (None, None): + top_n = 20 # could be a parameter, but would need to adjust cache handling + + # Get registration status details + if attendance_type: + registrations = Registration.objects.filter(tickets__attendance_type=attendance_type) + else: + registrations = Registration.objects.all() + registrations = registrations.values('affiliation', 'meeting__number') + + # Count per canonicalized affiliation + organization = dict() + meetings_set = set() + org_totals = defaultdict(int) + data_map = defaultdict(dict) # {org: {meeting: count}} + + for reg in registrations: + meeting = reg['meeting__number'] + meetings_set.add(meeting) + affiliation = canonicalize_affiliation(reg['affiliation']) or "Unspecified" + organization[affiliation] = organization.get(affiliation, 0) + 1 + org_totals[affiliation] = org_totals.get(affiliation, 0) + 1 + data_map[affiliation][meeting] = data_map[affiliation].get(meeting, 0) + 1 + + # ── Step 2: Sort meetings numerically rather than alphabetically ── + sorted_meetings = sorted(meetings_set, key=lambda x: int(x) if x.isdigit() else x) + + # ── Step 3: Get top N countries ── + top_orgs = sorted( + org_totals.keys(), + key=lambda c: org_totals[c], + reverse=True + )[:top_n] + non_top_orgs = org_totals.keys() - top_orgs + other_totals = defaultdict(int) + for m in sorted_meetings: + other_totals[m] = 0 + for c in non_top_orgs: + other_totals[m] += int(data_map[c].get(m, 0)) + + # ── Step 4: Build Chart.js datasets ── + + datasets = [] + for idx, org in enumerate(top_orgs): + color = colors[idx % len(colors)] + datasets.append({ + 'label': org, + 'data': [data_map[org].get(m, 0) for m in sorted_meetings], + 'borderColor': color, + 'fill': False, + 'tension': 0.3, + 'pointColor': color, + 'pointBackgroundColor': color, + 'pointRadius': 4, + 'pointHoverRadius': 6, + 'borderWidth': 2, + }) + + # -- Step 4.bis handle the other -- datasets.append({ - 'label': org, - 'data': [data_map[org].get(m, 0) for m in sorted_meetings], - 'borderColor': color, + 'label': 'Other', + 'data': [other_totals.get(m, 0) for m in sorted_meetings], + 'borderColor': 'black', 'fill': False, 'tension': 0.3, - 'pointColor': color, - 'pointBackgroundColor': color, + 'pointColor': 'black', + 'pointBackgroundColor': 'black', 'pointRadius': 4, 'pointHoverRadius': 6, 'borderWidth': 2, }) - - # -- Step 4.bis handle the other -- - datasets.append({ - 'label': 'Other', - 'data': [other_totals.get(m, 0) for m in sorted_meetings], - 'borderColor': 'black', - 'fill': False, - 'tension': 0.3, - 'pointColor': 'black', - 'pointBackgroundColor': 'black', - 'pointRadius': 4, - 'pointHoverRadius': 6, - 'borderWidth': 2, - }) + cache.set( + cache_key, + (sorted_meetings, datasets), + settings.STATS_TIMELINE_CACHE_TIMEOUT, + ) return sorted_meetings, datasets -def get_country_data_for_meetings(top_n, attendance_type=None): +def get_country_data_for_meetings(attendance_type=None): """Get country participation data for meetings timeline chart. Args: - top_n: Number of top countries to include. attendance_type: Optional filter for attendance type (e.g., 'onsite'). Returns: Tuple of (sorted_meetings, datasets) for Chart.js. """ - # Get registration status counts, aggregated by country_code - if attendance_type: - registrations = Registration.objects.filter(tickets__attendance_type=attendance_type) - else: - registrations = Registration.objects.all() - queryset = ( - registrations - .values( - 'meeting__number', # e.g. "118", "119", "120" - 'country_code' # country code of the participant + cache_key = f'stats:get_country_data_for_meetings:{attendance_type}' + sorted_meetings, datasets = cache.get(cache_key, (None, None)) + if (sorted_meetings, datasets) == (None, None): + top_n = 10 # could be a parameter, but would need to adjust cache handling + # Get registration status counts, aggregated by country_code + if attendance_type: + registrations = Registration.objects.filter(tickets__attendance_type=attendance_type) + else: + registrations = Registration.objects.all() + queryset = ( + registrations + .values( + 'meeting__number', # e.g. "118", "119", "120" + 'country_code' # country code of the participant + ) + .annotate(participant_count=Count('id')) + .order_by('meeting__number') # chronological order ) - .annotate(participant_count=Count('id')) - .order_by('meeting__number') # chronological order - ) - -# ── Step 1: Collect all meetings and country totals ── - meetings_set = set() - country_totals = defaultdict(int) - data_map = defaultdict(dict) # {country: {meeting: count}} - - for row in queryset: - meeting = row['meeting__number'] - country = row['country_code'] - count = row['participant_count'] - - meetings_set.add(meeting) - country_totals[country] += count - data_map[country][meeting] = count - - # ── Step 2: Sort meetings numerically rather than alphabetically ── - sorted_meetings = sorted(meetings_set, key=lambda x: int(x) if x.isdigit() else x) - - # ── Step 3: Get top N countries ── - top_countries = sorted( - country_totals.keys(), - key=lambda c: country_totals[c], - reverse=True - )[:top_n] - - # -- Step 3.bis do the 'other' category -- - non_top_countries = country_totals.keys() - top_countries - other_totals = defaultdict(int) - for m in sorted_meetings: - other_totals[m] = 0 - for c in non_top_countries: - other_totals[m] += int(data_map[c].get(m, 0)) - - # ── Step 4: Build Chart.js datasets ── - - datasets = [] - for idx, country in enumerate(top_countries): - color = colors[idx % len(colors)] + + # ── Step 1: Collect all meetings and country totals ── + meetings_set = set() + country_totals = defaultdict(int) + data_map = defaultdict(dict) # {country: {meeting: count}} + + for row in queryset: + meeting = row['meeting__number'] + country = row['country_code'] + count = row['participant_count'] + + meetings_set.add(meeting) + country_totals[country] += count + data_map[country][meeting] = count + + # ── Step 2: Sort meetings numerically rather than alphabetically ── + sorted_meetings = sorted(meetings_set, key=lambda x: int(x) if x.isdigit() else x) + + # ── Step 3: Get top N countries ── + top_countries = sorted( + country_totals.keys(), + key=lambda c: country_totals[c], + reverse=True + )[:top_n] + + # -- Step 3.bis do the 'other' category -- + non_top_countries = country_totals.keys() - top_countries + other_totals = defaultdict(int) + for m in sorted_meetings: + other_totals[m] = 0 + for c in non_top_countries: + other_totals[m] += int(data_map[c].get(m, 0)) + + # ── Step 4: Build Chart.js datasets ── + + datasets = [] + for idx, country in enumerate(top_countries): + color = colors[idx % len(colors)] + datasets.append({ + 'label': country, + 'data': [data_map[country].get(m, 0) for m in sorted_meetings], + 'borderColor': color, + 'fill': False, + 'tension': 0.3, + 'pointColor': color, + 'pointBackgroundColor': color, + 'pointRadius': 4, + 'pointHoverRadius': 6, + 'borderWidth': 2, + }) + + # -- Step 4.bis handle the other -- datasets.append({ - 'label': country, - 'data': [data_map[country].get(m, 0) for m in sorted_meetings], - 'borderColor': color, + 'label': 'Other', + 'data': [other_totals.get(m, 0) for m in sorted_meetings], + 'borderColor': 'black', 'fill': False, 'tension': 0.3, - 'pointColor': color, - 'pointBackgroundColor': color, + 'pointColor': 'black', + 'pointBackgroundColor': 'black', 'pointRadius': 4, 'pointHoverRadius': 6, 'borderWidth': 2, }) - - # -- Step 4.bis handle the other -- - datasets.append({ - 'label': 'Other', - 'data': [other_totals.get(m, 0) for m in sorted_meetings], - 'borderColor': 'black', - 'fill': False, - 'tension': 0.3, - 'pointColor': 'black', - 'pointBackgroundColor': 'black', - 'pointRadius': 4, - 'pointHoverRadius': 6, - 'borderWidth': 2, - }) + cache.set( + cache_key, + (sorted_meetings, datasets), + settings.STATS_TIMELINE_CACHE_TIMEOUT, + ) return sorted_meetings, datasets @@ -333,60 +352,67 @@ def get_data_for_meetings(): Returns: Tuple of (sorted_meetings, datasets) for Chart.js. """ - # Get registration status counts, aggregated by ticket types - registrations = Registration.objects.filter(tickets__attendance_type__in=['onsite', 'remote']) - queryset = ( - registrations - .values( - 'meeting__number', # e.g. "118", "119", "120" - 'tickets__attendance_type' + cache_key = "stats:get_data_for_meetings" + sorted_meetings, datasets = cache.get(cache_key, (None, None)) + if (sorted_meetings, datasets) == (None, None): + # Get registration status counts, aggregated by ticket types + registrations = Registration.objects.filter(tickets__attendance_type__in=['onsite', 'remote']) + queryset = ( + registrations + .values( + 'meeting__number', # e.g. "118", "119", "120" + 'tickets__attendance_type' + ) + .annotate(participant_count=Count('id')) + .order_by('meeting__number') # chronological order ) - .annotate(participant_count=Count('id')) - .order_by('meeting__number') # chronological order - ) - -# ── Step 1: Collect all meetings and tickets totals ── - meetings_set = set() - tickets_totals = defaultdict(int) - data_map = defaultdict(dict) # {ticket: {meeting: count}} - - for row in queryset: - meeting = row['meeting__number'] - ticket = row['tickets__attendance_type'] - count = row['participant_count'] - - meetings_set.add(meeting) - tickets_totals[ticket] += count - data_map[ticket][meeting] = count - - # ── Step 2: Sort meetings numerically rather than alphabetically ── - sorted_meetings = sorted(meetings_set, key=lambda x: int(x) if x.isdigit() else x) - ticket_types = tickets_totals.keys() - # ── Step 4: Build Chart.js datasets ── - # Color palette for lines - colors = [ '#FF6384', '#36A2EB'] - - datasets = [] - for idx, ticket_type in enumerate(ticket_types): - color = colors[idx % len(colors)] - datasets.append({ - 'label': ticket_type, - 'data': [data_map[ticket_type].get(m, 0) for m in sorted_meetings], - 'borderColor': color, - 'backgroundColor': color + '99', # 60% opacity fill - 'fill': True, - 'tension': 0.0, - 'pointColor': color, - 'pointBackgroundColor': color, - 'pointRadius': 4, - 'pointHoverRadius': 6, - 'borderWidth': 2, - }) - + # ── Step 1: Collect all meetings and tickets totals ── + meetings_set = set() + tickets_totals = defaultdict(int) + data_map = defaultdict(dict) # {ticket: {meeting: count}} + + for row in queryset: + meeting = row['meeting__number'] + ticket = row['tickets__attendance_type'] + count = row['participant_count'] + + meetings_set.add(meeting) + tickets_totals[ticket] += count + data_map[ticket][meeting] = count + + # ── Step 2: Sort meetings numerically rather than alphabetically ── + sorted_meetings = sorted(meetings_set, key=lambda x: int(x) if x.isdigit() else x) + ticket_types = tickets_totals.keys() + + # ── Step 4: Build Chart.js datasets ── + # Color palette for lines + colors = [ '#FF6384', '#36A2EB'] + + datasets = [] + for idx, ticket_type in enumerate(ticket_types): + color = colors[idx % len(colors)] + datasets.append({ + 'label': ticket_type, + 'data': [data_map[ticket_type].get(m, 0) for m in sorted_meetings], + 'borderColor': color, + 'backgroundColor': color + '99', # 60% opacity fill + 'fill': True, + 'tension': 0.0, + 'pointColor': color, + 'pointBackgroundColor': color, + 'pointRadius': 4, + 'pointHoverRadius': 6, + 'borderWidth': 2, + }) + cache.set( + cache_key, + (sorted_meetings, datasets), + settings.STATS_TIMELINE_CACHE_TIMEOUT, + ) return sorted_meetings, datasets -def meetings_timeline(request, stats_type='country', top_n=10): +def meetings_timeline(request, stats_type='country'): """Render the meetings timeline page with participation statistics over time. Args: @@ -397,33 +423,35 @@ def meetings_timeline(request, stats_type='country', top_n=10): Returns: Rendered response for the meetings timeline template. """ - if stats_type == 'total': total_labels, total_data_sets = get_data_for_meetings() + in_person_labels = ([], []) + in_person_data_sets = ([], []) + top_n = len(total_data_sets) - 1 # subtract one because we don't count "other" elif stats_type == 'affiliation': - top_n = 20 # For affiliations we can have more entries, so show more by default - total_labels, total_data_sets = get_affiliation_data_for_meetings(top_n) - in_person_labels, in_person_data_sets = get_affiliation_data_for_meetings(top_n, attendance_type='onsite') + total_labels, total_data_sets = get_affiliation_data_for_meetings() + in_person_labels, in_person_data_sets = get_affiliation_data_for_meetings(attendance_type='onsite') + top_n = len(total_data_sets) - 1 # subtract one because we don't count "other" elif stats_type == 'country': - total_labels, total_data_sets = get_country_data_for_meetings(top_n) - in_person_labels, in_person_data_sets = get_country_data_for_meetings(top_n, attendance_type='onsite') + total_labels, total_data_sets = get_country_data_for_meetings() + in_person_labels, in_person_data_sets = get_country_data_for_meetings(attendance_type='onsite') + top_n = len(total_data_sets) - 1 # subtract one because we don't count "other" else: return HttpResponseRedirect(urlreverse("ietf.stats.views.stats_index")) - # Serialize to JSON for safe injection into the template - total_chart_data = json.dumps({ + total_chart_data = { 'labels': total_labels, 'datasets': total_data_sets, - }) + } # On per country/affiliation have a separate graph for inperson if stats_type == 'total': - in_person_chart_data = json.dumps(None) + in_person_chart_data = None else: - in_person_chart_data = json.dumps({ + in_person_chart_data = { 'labels': in_person_labels, 'datasets': in_person_data_sets, - }) + } # Prepare the list of choice buttons for the template possible_stats_types = [ @@ -558,8 +586,7 @@ def meeting_stats(request, meeting_number=None, stats_type='country'): else: return HttpResponseRedirect(urlreverse("ietf.stats.views.stats_index")) - # Serialize to JSON for safe injection into the template - total_chart_data = json.dumps({ + total_chart_data = { 'labels': total_labels, 'datasets': [{ 'label': 'Total Registrations by ' + stats_type, @@ -567,8 +594,8 @@ def meeting_stats(request, meeting_number=None, stats_type='country'): 'borderColor': '#ffffff', 'borderWidth': 2, }] - }) - in_person_chart_data = json.dumps({ + } + in_person_chart_data = { 'labels': in_person_labels, 'datasets': [{ 'label': 'In Person Registrations by ' + stats_type, @@ -576,7 +603,7 @@ def meeting_stats(request, meeting_number=None, stats_type='country'): 'borderColor': '#ffffff', 'borderWidth': 2, }] - }) + } # Prepare the list of choice buttons for the template possible_stats_types = [ diff --git a/ietf/templates/stats/meeting_stats.html b/ietf/templates/stats/meeting_stats.html index 70ce363f7a..b32f5a4046 100644 --- a/ietf/templates/stats/meeting_stats.html +++ b/ietf/templates/stats/meeting_stats.html @@ -2,10 +2,13 @@ {% load origin %} {% origin %} {% load ietf_filters static django_bootstrap5 %} -{% block pagehead %} +{% block js %} + {{ total_chart_data|json_script:"total-chart-data" }} + {{ in_person_chart_data|json_script:"in-person-chart-data" }} + {% endblock %} {% block content %} {% origin %} @@ -55,61 +58,4 @@

In Person Registrations by {{ stats_type|title }} ({{ in_person_total}} in t - - {% endblock %} \ No newline at end of file diff --git a/ietf/templates/stats/meetings_timeline.html b/ietf/templates/stats/meetings_timeline.html index e89eae5196..65fb4e09c0 100644 --- a/ietf/templates/stats/meetings_timeline.html +++ b/ietf/templates/stats/meetings_timeline.html @@ -2,11 +2,15 @@ {% load origin %} {% origin %} {% load ietf_filters static django_bootstrap5 %} -{% block pagehead %} +{% block js %} + {{ total_chart_data|json_script:"total-chart-data" }} + {{ in_person_chart_data|json_script:"in-person-chart-data" }} + {{ stats_type|json_script:"stats-type-data" }} + {% endblock %} {% block content %} {% origin %} @@ -15,6 +19,7 @@

Statistics for IETF Meeting Registrations {% endblock %}

+
@@ -70,91 +75,4 @@

In Person Registrations by {{ stats_type|title }}

{% endif %}
- - {% endblock %} \ No newline at end of file diff --git a/package.json b/package.json index 57642d7860..6f61aaba29 100644 --- a/package.json +++ b/package.json @@ -146,6 +146,8 @@ "ietf/static/js/manage-community-list.js", "ietf/static/js/manage-review-requests.js", "ietf/static/js/meeting-interim-request.js", + "ietf/static/js/meeting_stats.js", + "ietf/static/js/meeting_timeline.js", "ietf/static/js/moment.js", "ietf/static/js/navbar-doc-search.js", "ietf/static/js/password_strength.js",