From ca5a8d0864a8ec77cb5a3ab53da729193b7538ab Mon Sep 17 00:00:00 2001 From: Chris Want Date: Fri, 27 Jun 2025 09:53:06 -0600 Subject: [PATCH 01/20] Move the time/timezone rendering into own partial, with optional timezone override. (Part 1 of timezone controls.) --- app/views/events/_event.html.erb | 2 +- app/views/events/_event_time_div.html.erb | 13 +++++++++++++ app/views/events/show.html.erb | 8 ++------ config/locales/en.yml | 5 +++++ 4 files changed, 21 insertions(+), 7 deletions(-) create mode 100644 app/views/events/_event_time_div.html.erb diff --git a/app/views/events/_event.html.erb b/app/views/events/_event.html.erb index 687af7444..b06b24bd9 100644 --- a/app/views/events/_event.html.erb +++ b/app/views/events/_event.html.erb @@ -34,7 +34,7 @@ <%= elixir_node_icon %> <% end -%> -

<%= neatly_printed_date_range(event.start, event.end) %>

+ <%= render partial: 'events/event_time_div', locals: {event: event} %> <% if event.onsite? %> <% location = [event.city, event.country].reject { |field_value| field_value.blank? }.join(", ") %> diff --git a/app/views/events/_event_time_div.html.erb b/app/views/events/_event_time_div.html.erb new file mode 100644 index 000000000..138a3f776 --- /dev/null +++ b/app/views/events/_event_time_div.html.erb @@ -0,0 +1,13 @@ +<% # Fake the timezone if passed as an argument + if local_assigns[:timezone] + saved_timezone = event.timezone + event.timezone = timezone + end %> +
+

+ <%= t('events.date') %>: + <%= neatly_printed_date_range(event.start_local, event.end_local) %> +

+ <%= display_attribute(event, :timezone) %> +
+<% event.timezone = saved_timezone if local_assigns[:timezone] %> diff --git a/app/views/events/show.html.erb b/app/views/events/show.html.erb index 3f036f328..52574c7c7 100644 --- a/app/views/events/show.html.erb +++ b/app/views/events/show.html.erb @@ -56,12 +56,8 @@
- -

- Date: - <%= neatly_printed_date_range(@event.start, @event.end) %> -

- <%= display_attribute(@event, :timezone) %> + <%= render partial: 'event_time_div', locals: {event: @event} %> + <%= display_attribute(@event, :duration) %> <%= display_attribute(@event, :language) { |value| render_language_name(value) } %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 727d7ca70..362647a9e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -634,6 +634,11 @@ en: prompts: language: 'Select a language...' long: 'Long events' + date: 'Date' + labels: + start: 'Start (in specified time zone)' + end: 'End (in specified time zone)' + view_event: 'View event' pick_date: 'Pick a range of dates for a better collection of events.' show_past_events: one: Show %{count} past event From 5d927afac942b883d88317b444784b654e384169 Mon Sep 17 00:00:00 2001 From: Chris Want Date: Fri, 27 Jun 2025 09:54:44 -0600 Subject: [PATCH 02/20] Create a session variable to hold a timezone override for views. (Part 2 of timezone controls.) --- app/controllers/application_controller.rb | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 13fc15d1f..5ccc734f7 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -7,6 +7,7 @@ class ApplicationController < ActionController::Base include PublicActivity::StoreController before_action :configure_permitted_parameters, if: :devise_controller? + before_action :timezone_from_params_or_session # Prevent CSRF attacks by raising an exception. # For APIs, you may want to use :null_session instead. @@ -159,4 +160,16 @@ def configure_permitted_parameters def allow_embedding response.headers.delete 'X-Frame-Options' end + + def timezone_from_params_or_session + # TODO? have a list of acceptable time zones, maybe via config? + tz = params[:tz] || session['tz'] + + session['tz'] = if (tz.blank? || tz == 'reset') + nil + elsif ActiveSupport::TimeZone[tz].present? + tz + end + end + end From 2b3645a7855dcf504f4bc5da43094d5a427c38fe Mon Sep 17 00:00:00 2001 From: Chris Want Date: Fri, 27 Jun 2025 09:57:16 -0600 Subject: [PATCH 03/20] Add user interface for controlling what timezone times are displayed in. (Part 3 of timezone controls.) --- app/assets/javascripts/events.js | 45 ++++++++++++++ app/assets/stylesheets/application.scss | 10 ++++ app/controllers/events_controller.rb | 29 ++++++++++ .../events/_event_timezone_control.html.erb | 58 +++++++++++++++++++ config/locales/en.yml | 19 ++++++ config/routes.rb | 1 + 6 files changed, 162 insertions(+) create mode 100644 app/views/events/_event_timezone_control.html.erb diff --git a/app/assets/javascripts/events.js b/app/assets/javascripts/events.js index 281de07fd..d946396d0 100644 --- a/app/assets/javascripts/events.js +++ b/app/assets/javascripts/events.js @@ -29,6 +29,48 @@ var Events = { } } +function updateDateTimes(render_controls = false) { + // Assemble the data parameters for the query string + var event_ids = $.map($('.time-with-zone'), function(div) { + return $(div).data('event-id') + }) + var timezone = $('input[type=radio][name=timezone-choice]:checked').val(); + var data = { event_ids: event_ids, + tz: timezone} + if (render_controls) { + data['browser_timezone'] = Intl.DateTimeFormat().resolvedOptions().timeZone; + } + + // Server renders the various DOM ids that need updating + $.ajax({ + url: "/events/event_time_data.json", + data: data, + context: document.body + }).done(function(elements) { + // Replace content of server-rendered ids with new content + elements.forEach(function(element) { + $(element['id']).replaceWith(element['html']) + }) + + if (render_controls) { + // This is just run on initial page load, set up controls + // (controls aren't rendered on page initially served) + $('#timezone-controls .close-timezone-controls').click(function() { + $('#timezone-select').collapse('hide') + $('#timezone-controls .show-timezone-controls').show(); + }); + $('#timezone-controls .show-timezone-controls').click(function() { + $(this).hide(); + }); + $('input[type=radio][name=timezone-choice]').change(function() { + updateDateTimes(); + $('.timezone-display-value').toggleClass('unselected') + }) + } + }); + +} + $(document).on('change', '[data-role="online-switch"]', function () { Events.switchAddressFields(); }); @@ -40,4 +82,7 @@ $(document).on('change', '.event_cost_basis', function () { $(document).on('ready turbolinks:load', function () { Events.switchCostFields(); Events.switchAddressFields(); + if ($('.time-with-zone').length) { + updateDateTimes(true); + } }); diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index f3974e377..05918e8ed 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -1121,3 +1121,13 @@ td.day .calendar-text { .tag-topic, .tag-operation { text-decoration: none; } + +#timezone-controls { + overflow: auto; + // font-size: 0.7em; + label { font-weight: inherit; } + fieldset legend { font-size: inherit; } + #timezone-display .unselected { display: none; } + .center-block { width: 80%; } + .close-timezone-controls { float: right; } +} diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 7df0b4b41..2f7e8dfc1 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -226,6 +226,35 @@ def redirect redirect_to @event.url, allow_other_host: true end + def event_time_data + # Serve JSON of some DOM ids that need updating, with rendered HTML content + respond_to do |format| + event_ids = params[:event_ids] + timezone = session[:tz] + browser_timezone = params[:browser_timezone] + + # Render times for the following event ids + out = (event_ids || []).map do |event_id| + event = Event.find(event_id.to_i) + html = render_to_string partial: 'events/event_time_div', + formats: [:html], + locals: { event: event, timezone: timezone } + { id: "#event-time-#{event.id}", + html: html} + end + if browser_timezone + # Render timezone controls + control_html = render_to_string partial: 'events/event_timezone_control', + formats: [:html], + locals: { browser_timezone: browser_timezone, + timezone: browser_timezone } + out << { id: '#timezone-controls', + html: control_html } + end + format.json { render json: out} + end + end + private # Use callbacks to share common setup or constraints between actions. diff --git a/app/views/events/_event_timezone_control.html.erb b/app/views/events/_event_timezone_control.html.erb new file mode 100644 index 000000000..6c61ec515 --- /dev/null +++ b/app/views/events/_event_timezone_control.html.erb @@ -0,0 +1,58 @@ +<%# If browser_timezone is not set, do not render user interface %> +
+
+ +

+ + + <%= t('events.timezone_controls.all_events_in_local_html') %> + + <% if local_assigns[:browser_timezone] %> + + <%= t('events.timezone_controls.all_events_in_timezone_html', + timezone: normalized_icu_timezone(browser_timezone)) %> + + <% end %> + + + <% if local_assigns[:browser_timezone] %> + + <% end %> +

+ + <% if local_assigns[:browser_timezone] %> +
+
+ + +
+ <%= t('events.timezone_controls.controls_legend') %> + +
+ />  + +
+ +
+ />  + +
+
+
+ <% end %> + +
+
diff --git a/config/locales/en.yml b/config/locales/en.yml index 362647a9e..876b1232a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -643,6 +643,25 @@ en: show_past_events: one: Show %{count} past event other: Show %{count} past events + info_button_header: 'What are events in %{site}?' + info_button_body_html: >- +

An event in %{site} is a link to a single training event sourced by a + provider along with description and other meta information (e.g. date, location, audience, + ontological categorization, keywords, etc.).

+

Training events can be added manually or automatically harvested from a provider's website.

+

If your website contains training events that you wish to include in %{site}, + see here for details on automatic registration

+ breadcrumb_index: Events + timezone_controls: + all_events_in_local_html: > + Note: all times are shown in the timezone in which each event occurs. + all_events_in_timezone_html: > + Note: all times are shown in the %{timezone} timezone. + controls_show: 'Change...' + controls_legend: 'When displaying times, use:' + local_time_select: 'the timezone in which the event occurs' + timezone_select_html: '%{timezone} timezone (detected from your browser)' + close: 'Close' materials: hints: authors: > diff --git a/config/routes.rb b/config/routes.rb index 5f9613f6f..ee89fc99d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -64,6 +64,7 @@ collection do get :count get :calendar, format: %i[js html] + get :event_time_data post :preview end member do From 037f454768d9355378938f857580cad15d00dc4c Mon Sep 17 00:00:00 2001 From: Chris Want Date: Fri, 27 Jun 2025 09:58:31 -0600 Subject: [PATCH 04/20] Show timezone controls on pages that need it. (Part 4 of timezone controls.) --- app/views/common/_associated_events.html.erb | 1 + app/views/events/index.html.erb | 1 + app/views/events/show.html.erb | 2 ++ app/views/static/home/_upcoming_events.html.erb | 5 ++++- 4 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/views/common/_associated_events.html.erb b/app/views/common/_associated_events.html.erb index 4cfccfb43..84d42e6af 100644 --- a/app/views/common/_associated_events.html.erb +++ b/app/views/common/_associated_events.html.erb @@ -30,5 +30,6 @@ <%= link_to "View all #{name.pluralize(shown_count)} & filter", inc_expired_link, class: 'btn btn-xs btn-default' %> <% end %> + <%= render partial: 'events/event_timezone_control' %> <%= render partial: 'common/masonry_grid', locals: { objects: resources } %>
diff --git a/app/views/events/index.html.erb b/app/views/events/index.html.erb index cab70d890..5c0167bde 100644 --- a/app/views/events/index.html.erb +++ b/app/views/events/index.html.erb @@ -42,6 +42,7 @@ <% if @events.any? %>
+ <%= render partial: 'events/event_timezone_control' %> <%= render partial: 'common/masonry_grid', locals: { objects: @events } %> <%= render partial: "search/common/pagination_bar", locals: { resources: @events } %> diff --git a/app/views/events/show.html.erb b/app/views/events/show.html.erb index 52574c7c7..cc91b7a8c 100644 --- a/app/views/events/show.html.erb +++ b/app/views/events/show.html.erb @@ -56,6 +56,8 @@
+ + <%= render partial: 'events/event_timezone_control' %> <%= render partial: 'event_time_div', locals: {event: @event} %> <%= display_attribute(@event, :duration) %> diff --git a/app/views/static/home/_upcoming_events.html.erb b/app/views/static/home/_upcoming_events.html.erb index 31d223e67..075422812 100644 --- a/app/views/static/home/_upcoming_events.html.erb +++ b/app/views/static/home/_upcoming_events.html.erb @@ -1,7 +1,10 @@ <% cache(['home', 'upcoming_events', @events]) do %>
-

<%= link_to('Upcoming events', events_path, class: 'home-title-link' ) %>

+

+ <%= link_to(t('home.upcoming_events'), events_path, class: 'home-title-link' ) %> +

+ <%= render partial: 'events/event_timezone_control' %>
    <%= render partial: 'common/masonry_grid', locals: { objects: @events } %>
From 230de2c9993d519a9b3fc01a12758416ce7ceada Mon Sep 17 00:00:00 2001 From: Chris Want Date: Fri, 27 Jun 2025 15:22:03 -0600 Subject: [PATCH 05/20] Translations of time, date, timezones for event display (via ffi-icu gem). (Part 5 of timezone controls.) --- Gemfile | 1 + Gemfile.lock | 3 +++ app/helpers/events_helper.rb | 23 ++++++++++++++++++++--- app/views/events/_event_time_div.html.erb | 2 +- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/Gemfile b/Gemfile index 98b174175..1be27435b 100644 --- a/Gemfile +++ b/Gemfile @@ -17,6 +17,7 @@ gem 'country_select' gem 'devise' gem 'devise_invitable' gem 'eventbrite_sdk' +gem 'ffi-icu' gem 'font-awesome-sass', '~> 4.7.0' # Prefer V4 icon styles gem 'friendly_id' gem 'geocoder' diff --git a/Gemfile.lock b/Gemfile.lock index 0f8b3d923..f4f1fb389 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -207,6 +207,8 @@ GEM faraday-net_http (3.4.2) net-http (~> 0.5) ffi (1.15.5) + ffi-icu (0.5.3) + ffi (~> 1.0, >= 1.0.9) font-awesome-sass (4.7.0) sass (>= 3.2) friendly_id (5.5.0) @@ -857,6 +859,7 @@ DEPENDENCIES devise devise_invitable eventbrite_sdk + ffi-icu font-awesome-sass (~> 4.7.0) friendly_id geocoder diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 4a72a3a08..dea90e1e6 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -148,14 +148,19 @@ def neatly_printed_date_range(start, finish = nil) end if finish.blank? || differing.empty? - out = start.strftime(DATE_STRF) + out = l(start, format: DATE_STRF) # Don't show time component if they are set to midnight since that is the default if no time specified. # Revisit this decision if any events start occurring at midnight (timezone issue?)! show_time = (start.hour != 0 || start.min != 0) || (finish.present? && (finish.hour != 0 || finish.min != 0)) if show_time - out << " @ #{start.strftime(TIME_STRF)}" - out << " - #{finish.strftime(TIME_STRF)}" if finish && (finish.hour != start.hour || finish.min != start.min) + date_time_separator = if (I18n.locale.to_s == 'fr') + ', ' + else + ' @ ' + end + out << "#{date_time_separator}#{l(start, format: TIME_STRF)}" + out << " - #{l(finish, format: TIME_STRF)}" if finish && (finish.hour != start.hour || finish.min != start.min) end out elsif differing.any? @@ -163,4 +168,16 @@ def neatly_printed_date_range(start, finish = nil) end end end + + def normalized_icu_timezone(name) + # Get more standardized name from TZinfo... + tz_name = ActiveSupport::TimeZone[name].tzinfo.name + # The ICU library we use want's underscores not spaces ... + tz_name = tz_name.gsub(' ', '_') + formatter = ICU::TimeFormatting.create(locale: I18n.locale.to_s, + zone: tz_name, + skeleton: '%zzzz') + formatter.format(Time.now) + end + end diff --git a/app/views/events/_event_time_div.html.erb b/app/views/events/_event_time_div.html.erb index 138a3f776..a6ca67958 100644 --- a/app/views/events/_event_time_div.html.erb +++ b/app/views/events/_event_time_div.html.erb @@ -8,6 +8,6 @@ <%= t('events.date') %>: <%= neatly_printed_date_range(event.start_local, event.end_local) %>

- <%= display_attribute(event, :timezone) %> + <%= display_attribute(event, :timezone) {|v| normalized_icu_timezone(v) } %>
<% event.timezone = saved_timezone if local_assigns[:timezone] %> From 26ac4d182a7cb71260008d19fbd09fd06e2f79b7 Mon Sep 17 00:00:00 2001 From: Chris Want Date: Tue, 9 Sep 2025 14:32:55 -0600 Subject: [PATCH 06/20] Show timezone controls on calendar view, but don't do anything (yet). Also, localize dates for longer events. (Part 7 of timezone controls.) --- app/helpers/events_helper.rb | 2 +- app/views/events/index.html.erb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index dea90e1e6..4fb53b503 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -164,7 +164,7 @@ def neatly_printed_date_range(start, finish = nil) end out elsif differing.any? - "#{start.strftime(differing.join(' '))} - #{finish.strftime(DATE_STRF)}" + "#{l(start, format: differing.join(' '))} - #{l(finish, format: DATE_STRF)}" end end end diff --git a/app/views/events/index.html.erb b/app/views/events/index.html.erb index 5c0167bde..4c9d50bb2 100644 --- a/app/views/events/index.html.erb +++ b/app/views/events/index.html.erb @@ -41,8 +41,8 @@ <%# ACTUAL RESULTS LIST %> <% if @events.any? %>
+ <%= render partial: 'events/event_timezone_control' %>
- <%= render partial: 'events/event_timezone_control' %> <%= render partial: 'common/masonry_grid', locals: { objects: @events } %> <%= render partial: "search/common/pagination_bar", locals: { resources: @events } %> From c6eec5ba45d226480200927ec2f6c1a1f8b77f8b Mon Sep 17 00:00:00 2001 From: Chris Want Date: Tue, 2 Sep 2025 09:54:46 -0600 Subject: [PATCH 07/20] Let the user create/edit start/end times in the user interface in the specified time zone. (Otherwise they have to do mental math to add times in GMT.) --- app/controllers/events_controller.rb | 22 +++++++- app/views/events/_form.html.erb | 16 +++--- test/controllers/events_controller_test.rb | 61 ++++++++++++++++++++++ 3 files changed, 92 insertions(+), 7 deletions(-) diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 2f7e8dfc1..56c213db4 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -108,6 +108,7 @@ def clone # GET /events/1/edit def edit authorize @event + shift_times_to_timezone_for_editing end # GET /events/1/report @@ -155,6 +156,8 @@ def check_exists def create authorize Event @event = Event.new(event_params) + # TODO: should time only shift for timezone if format is HTML? + shift_times_to_utc_for_saving @event.user = current_user @event.space = current_space @@ -174,8 +177,11 @@ def create # PATCH/PUT /events/1.json def update authorize @event + @event.assign_attributes(event_params) + # TODO: should time only shift for timezone if format is HTML? + shift_times_to_utc_for_saving respond_to do |format| - if @event.update(event_params) + if @event.save @event.create_activity(:update, owner: current_user) if @event.log_update_activity? format.html { redirect_to @event, notice: 'Event was successfully updated.' } format.json { render :show, status: :ok, location: @event } @@ -262,6 +268,20 @@ def set_event @event = Event.friendly.find(params[:id]) end + def shift_times_to_timezone_for_editing + if (@event&.timezone) + @event.start = @event.start&.in_time_zone(@event.timezone.to_s) + @event.end = @event.end&.in_time_zone(@event.timezone.to_s) + end + end + + def shift_times_to_utc_for_saving + if (@event&.timezone) + @event.start = @event.start&.change(zone: @event.timezone.to_s) + @event.end = @event.end&.change(zone: @event.timezone.to_s) + end + end + # Never trust parameters from the scary internet, only allow the white list through. def event_params params.require(:event).permit(:external_id, :title, :subtitle, :url, :organizer, :last_scraped, :scraper_record, diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index 7dbb92a6b..13557daca 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -39,16 +39,20 @@ <%= f.input :description, as: :markdown_area, input_html: { rows: '5', title: t('events.hints.description') }, field_lock: true %> - - <%= f.input :start, as: :datetime_picker, field_lock: true, input_html: { title: t('events.hints.start') } %> - - - <%= f.input :end, as: :datetime_picker, field_lock: true, input_html: { title: t('events.hints.end') } %> - <%= f.input :timezone, as: :time_zone, field_lock: true, priority: priority_time_zones, input_html: { class: 'js-select2', title: t('events.hints.timezone') } %> + <%# We need to assume that these start/end times are in the selected timezone. + # This will need some shifting in the controller when saving, and some unshifting when loading %> + + <%= f.input :start, as: :datetime_picker, field_lock: true, label: t('events.labels.start'), + input_html: { title: t('events.hints.start') } %> + + + <%= f.input :end, as: :datetime_picker, field_lock: true, label: t('events.labels.end'), + input_html: { title: t('events.hints.end') } %> + <%= f.input :duration, as: :string, input_html: { title: t('events.hints.duration') } %> diff --git a/test/controllers/events_controller_test.rb b/test/controllers/events_controller_test.rb index e3004fe7b..5db2fc553 100644 --- a/test/controllers/events_controller_test.rb +++ b/test/controllers/events_controller_test.rb @@ -383,6 +383,67 @@ class EventsControllerTest < ActionController::TestCase assert_response :forbidden end + test 'should allow user to edit/create events in the specified timezone on the webpage' do + sign_in users(:admin) + event = events(:training_event) # Has non-trivial timezone + + # Time zone is Melbourne + assert_equal event.timezone, "Melbourne" + + # These are UTC + assert_equal event.start.to_s, "2021-09-20 23:15:00 UTC" + assert_equal event.end.to_s, "2021-09-21 01:00:00 UTC" + + get :edit, params: { id: event } + assert_response :success + + # Time zone in the form is Melbourne + timezone_select = css_select '#event_timezone' + value = timezone_select.search('option[@selected="selected"]').first.attr('value') + assert_equal value, 'Melbourne' + + # These are Melbourne time + start_select = css_select '#event_start' + value = start_select.first.attr('value') + assert_equal value, '2021-09-21 09:15:00 +1000' + + end_select = css_select '#event_end' + value = end_select.first.attr('value') + assert_equal value, '2021-09-21 11:00:00 +1000' + + # Update this in Melbourne timezone + # Both start and end are 1 minute later than about Melbourne times + patch :update, params: { + id: event, + event: { + start: '2021-09-21 09:16:00', + end: '2021-09-21 11:01:00' + } + } + event.reload + + # These are UTC + assert_equal event.start.to_s, "2021-09-20 23:16:00 UTC" + assert_equal event.end.to_s, "2021-09-21 01:01:00 UTC" + + # Check create, set one extra minute later in Melbourne timezone + assert_difference('Event.count') do + post :create, params: { event: { description: "Create time/timezone test", + title: "Create time/timezone test", + url: 'https://www.example.com/time/timezone/test', + timezone: 'Melbourne', + # These are Melbourne time + start: '2021-09-21 09:17:00', + end: '2021-09-21 11:02:00' } } + end + event = Event.find_by(title: 'Create time/timezone test') + assert_not_nil event + + # These are UTC + assert_equal event.start.to_s, "2021-09-20 23:17:00 UTC" + assert_equal event.end.to_s, "2021-09-21 01:02:00 UTC" + end + # DESTROY TESTS test 'should destroy event owned by user' do sign_in @event.user From dca60cded6c7d5f7f3a1d5ca22e4b19237d90809 Mon Sep 17 00:00:00 2001 From: Chris Want Date: Wed, 17 Jun 2026 12:58:56 -0600 Subject: [PATCH 08/20] Some adjustments need moving form Explora to Elixir. --- app/assets/javascripts/events.js | 76 +++++++++++------------ app/controllers/application_controller.rb | 2 + app/helpers/events_helper.rb | 5 +- app/models/event.rb | 4 +- app/views/events/_event_time_div.html.erb | 3 + config/locales/en.yml | 3 + 6 files changed, 51 insertions(+), 42 deletions(-) diff --git a/app/assets/javascripts/events.js b/app/assets/javascripts/events.js index d946396d0..8646a1eb1 100644 --- a/app/assets/javascripts/events.js +++ b/app/assets/javascripts/events.js @@ -26,49 +26,47 @@ var Events = { $('.address_content').show() } } - } -} - -function updateDateTimes(render_controls = false) { - // Assemble the data parameters for the query string - var event_ids = $.map($('.time-with-zone'), function(div) { - return $(div).data('event-id') - }) - var timezone = $('input[type=radio][name=timezone-choice]:checked').val(); - var data = { event_ids: event_ids, - tz: timezone} - if (render_controls) { - data['browser_timezone'] = Intl.DateTimeFormat().resolvedOptions().timeZone; - } + }, - // Server renders the various DOM ids that need updating - $.ajax({ - url: "/events/event_time_data.json", - data: data, - context: document.body - }).done(function(elements) { - // Replace content of server-rendered ids with new content - elements.forEach(function(element) { - $(element['id']).replaceWith(element['html']) + updateDateTimes: function (render_controls = false) { + // Assemble the data parameters for the query string + var event_ids = $.map($('.time-with-zone'), function(div) { + return $(div).data('event-id') }) - + var timezone = $('input[type=radio][name=timezone-choice]:checked').val(); + var data = { event_ids: event_ids, tz: timezone} if (render_controls) { - // This is just run on initial page load, set up controls - // (controls aren't rendered on page initially served) - $('#timezone-controls .close-timezone-controls').click(function() { - $('#timezone-select').collapse('hide') - $('#timezone-controls .show-timezone-controls').show(); - }); - $('#timezone-controls .show-timezone-controls').click(function() { - $(this).hide(); - }); - $('input[type=radio][name=timezone-choice]').change(function() { - updateDateTimes(); - $('.timezone-display-value').toggleClass('unselected') - }) + data['browser_timezone'] = Intl.DateTimeFormat().resolvedOptions().timeZone; } - }); + // Server renders the various DOM ids that need updating + $.ajax({ + url: "/events/event_time_data.json", + data: data, + context: document.body + }).done(function(elements) { + // Replace content of server-rendered ids with new content + elements.forEach(function(element) { + $(element['id']).replaceWith(element['html']) + }) + + if (render_controls) { + // This is just run on initial page load, set up controls + // (controls aren't rendered on page initially served) + $('#timezone-controls .close-timezone-controls').click(function() { + $('#timezone-select').collapse('hide') + $('#timezone-controls .show-timezone-controls').show(); + }); + $('#timezone-controls .show-timezone-controls').click(function() { + $(this).hide(); + }); + $('input[type=radio][name=timezone-choice]').change(function() { + Events.updateDateTimes(); + $('.timezone-display-value').toggleClass('unselected') + }) + } + }); + } } $(document).on('change', '[data-role="online-switch"]', function () { @@ -83,6 +81,6 @@ $(document).on('ready turbolinks:load', function () { Events.switchCostFields(); Events.switchAddressFields(); if ($('.time-with-zone').length) { - updateDateTimes(true); + Events.updateDateTimes(true); } }); diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 5ccc734f7..76b1d268a 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -170,6 +170,8 @@ def timezone_from_params_or_session elsif ActiveSupport::TimeZone[tz].present? tz end + + true end end diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 4fb53b503..efbed9f3e 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -171,7 +171,10 @@ def neatly_printed_date_range(start, finish = nil) def normalized_icu_timezone(name) # Get more standardized name from TZinfo... - tz_name = ActiveSupport::TimeZone[name].tzinfo.name + tz_name = ActiveSupport::TimeZone[name]&.tzinfo&.name + + return nil unless tz_name + # The ICU library we use want's underscores not spaces ... tz_name = tz_name.gsub(' ', '_') formatter = ICU::TimeFormatting.create(locale: I18n.locale.to_s, diff --git a/app/models/event.rb b/app/models/event.rb index 571acf006..ac76e7040 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -186,11 +186,11 @@ def end_utc end def start_local - set_to_local start + start&.in_time_zone(self&.timezone) end def end_local - set_to_local self.end + self.end&.in_time_zone(self&.timezone) end def started? diff --git a/app/views/events/_event_time_div.html.erb b/app/views/events/_event_time_div.html.erb index a6ca67958..d0da18de8 100644 --- a/app/views/events/_event_time_div.html.erb +++ b/app/views/events/_event_time_div.html.erb @@ -7,6 +7,9 @@

<%= t('events.date') %>: <%= neatly_printed_date_range(event.start_local, event.end_local) %> + <% unless event.timezone %> + <%= t('events.no_timezone_provided_gmt_assumed') %> + <% end %>

<%= display_attribute(event, :timezone) {|v| normalized_icu_timezone(v) } %>
diff --git a/config/locales/en.yml b/config/locales/en.yml index 876b1232a..a1ecd47dc 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -575,6 +575,7 @@ en: local_content_banner: title: Connecting from %{country_name}? body: Visit the %{community_name} portal to browse local training. + upcoming_events: 'Upcoming events' privacy: registration: |

When you register with %{site_name} we ask for a username, password and email address.

@@ -631,6 +632,8 @@ en: title: 'Add a title for your training event.' url: 'Location where the event and its details are advertised on the internet.' providers: The organisation providing the event metadata. + no_timezone_provided_gmt_assumed: > + (no timezone provided, GMT assumed.) prompts: language: 'Select a language...' long: 'Long events' From af8b11efc6f8972eab55c5544f3e5046da890e42 Mon Sep 17 00:00:00 2001 From: Chris Want Date: Wed, 17 Jun 2026 13:31:51 -0600 Subject: [PATCH 09/20] Make timezone controls optional (default: turned on). --- app/views/common/_associated_events.html.erb | 5 ++++- app/views/events/_event.html.erb | 6 +++++- app/views/events/index.html.erb | 5 ++++- app/views/events/show.html.erb | 13 +++++++++++-- app/views/static/home/_upcoming_events.html.erb | 6 +++++- config/tess.example.yml | 1 + 6 files changed, 30 insertions(+), 6 deletions(-) diff --git a/app/views/common/_associated_events.html.erb b/app/views/common/_associated_events.html.erb index 84d42e6af..1e9da692b 100644 --- a/app/views/common/_associated_events.html.erb +++ b/app/views/common/_associated_events.html.erb @@ -30,6 +30,9 @@ <%= link_to "View all #{name.pluralize(shown_count)} & filter", inc_expired_link, class: 'btn btn-xs btn-default' %> <% end %> - <%= render partial: 'events/event_timezone_control' %> + <% if TeSS::Config.feature.fetch('timezone_controls') %> + <%= render partial: 'events/event_timezone_control' %> + <% end %> + <%= render partial: 'common/masonry_grid', locals: { objects: resources } %>
diff --git a/app/views/events/_event.html.erb b/app/views/events/_event.html.erb index b06b24bd9..49775ff0d 100644 --- a/app/views/events/_event.html.erb +++ b/app/views/events/_event.html.erb @@ -34,7 +34,11 @@ <%= elixir_node_icon %> <% end -%> - <%= render partial: 'events/event_time_div', locals: {event: event} %> + <% if TeSS::Config.feature.fetch('timezone_controls') %> + <%= render partial: 'events/event_time_div', locals: {event: event} %> + <% else %> +

<%= neatly_printed_date_range(event.start, event.end) %>

+ <% end %> <% if event.onsite? %> <% location = [event.city, event.country].reject { |field_value| field_value.blank? }.join(", ") %> diff --git a/app/views/events/index.html.erb b/app/views/events/index.html.erb index 4c9d50bb2..aeaaa59f1 100644 --- a/app/views/events/index.html.erb +++ b/app/views/events/index.html.erb @@ -41,7 +41,10 @@ <%# ACTUAL RESULTS LIST %> <% if @events.any? %>
- <%= render partial: 'events/event_timezone_control' %> + <% if TeSS::Config.feature.fetch('timezone_controls') %> + <%= render partial: 'events/event_timezone_control' %> + <% end %> +
<%= render partial: 'common/masonry_grid', locals: { objects: @events } %> diff --git a/app/views/events/show.html.erb b/app/views/events/show.html.erb index cc91b7a8c..b460456a5 100644 --- a/app/views/events/show.html.erb +++ b/app/views/events/show.html.erb @@ -57,8 +57,17 @@
- <%= render partial: 'events/event_timezone_control' %> - <%= render partial: 'event_time_div', locals: {event: @event} %> + <% if TeSS::Config.feature.fetch('timezone_controls') %> + <%= render partial: 'events/event_timezone_control' %> + <%= render partial: 'event_time_div', locals: {event: @event} %> + <% else %> + +

+ Date: + <%= neatly_printed_date_range(@event.start, @event.end) %> +

+ <%= display_attribute(@event, :timezone) %> + <% end %> <%= display_attribute(@event, :duration) %> <%= display_attribute(@event, :language) { |value| render_language_name(value) } %> diff --git a/app/views/static/home/_upcoming_events.html.erb b/app/views/static/home/_upcoming_events.html.erb index 075422812..acbf2a5ce 100644 --- a/app/views/static/home/_upcoming_events.html.erb +++ b/app/views/static/home/_upcoming_events.html.erb @@ -4,7 +4,11 @@

<%= link_to(t('home.upcoming_events'), events_path, class: 'home-title-link' ) %>

- <%= render partial: 'events/event_timezone_control' %> + + <% if TeSS::Config.feature.fetch('timezone_controls') %> + <%= render partial: 'events/event_timezone_control' %> + <% end %> +
    <%= render partial: 'common/masonry_grid', locals: { objects: @events } %>
diff --git a/config/tess.example.yml b/config/tess.example.yml index 70560649e..95e9f0303 100644 --- a/config/tess.example.yml +++ b/config/tess.example.yml @@ -182,6 +182,7 @@ default: &default # UI sticky_navbar: false # when true, allows navbar (and header_notice if enabled) to stick to the top of the window and shrink when scrolling + timezone_controls: true # Possible features to disable: # biotools, topics, operations, sponsors, fairshare, county, ardc_fields_of_research, From 8ac0b2d359015dc3909af1e9dff43e6f9f077273 Mon Sep 17 00:00:00 2001 From: Chris Want Date: Wed, 8 Jul 2026 14:56:29 -0600 Subject: [PATCH 10/20] Per advice from Copilot: ensure the event_time_data action doesn't need auth. --- app/controllers/application_controller.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 76b1d268a..e034a9bcc 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -15,11 +15,13 @@ class ApplicationController < ActionController::Base # Should allow token authentication for API calls acts_as_token_authentication_handler_for User, except: [:index, :show, :embed, :calendar, :check_exists, :handle_error, :count, - :redirect] #only: [:new, :create, :edit, :update, :destroy] + :redirect, :event_time_data] + #only: [:new, :create, :edit, :update, :destroy] # User auth should be required in the web interface as well; it's here rather than in routes so that it # doesn't override the token auth, above. - before_action :authenticate_user!, except: [:index, :show, :embed, :calendar, :check_exists, :handle_error, :count, :redirect] + before_action :authenticate_user!, except: [:index, :show, :embed, :calendar, :check_exists, + :handle_error, :count, :redirect, :event_time_data] before_action :set_current_space before_action :set_current_user From 3058ae2672af828f5d31e497cdd3835e32d565bf Mon Sep 17 00:00:00 2001 From: Chris Want Date: Wed, 8 Jul 2026 14:56:58 -0600 Subject: [PATCH 11/20] Per advice from Copilot: better logic (and permissions) in event_time_data action. --- app/controllers/events_controller.rb | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 56c213db4..464d2b231 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -13,6 +13,8 @@ class EventsController < ApplicationController include FieldLockEnforcement include TopicCuration + MAX_EVENT_TIMEZONE_IDS = 200 + # GET /events # GET /events.json def index @@ -240,22 +242,23 @@ def event_time_data browser_timezone = params[:browser_timezone] # Render times for the following event ids - out = (event_ids || []).map do |event_id| - event = Event.find(event_id.to_i) + events = Event.where(id: Array(event_ids).uniq.first(MAX_EVENT_TIMEZONE_IDS)) + out = events.map do |event| + next unless policy(event).show? + html = render_to_string partial: 'events/event_time_div', formats: [:html], locals: { event: event, timezone: timezone } - { id: "#event-time-#{event.id}", - html: html} - end + + { id: "#event-time-#{event.id}", html: html} + end.compact if browser_timezone # Render timezone controls control_html = render_to_string partial: 'events/event_timezone_control', formats: [:html], locals: { browser_timezone: browser_timezone, timezone: browser_timezone } - out << { id: '#timezone-controls', - html: control_html } + out << { id: '#timezone-controls', html: control_html } end format.json { render json: out} end From 98f790ecb2936c63a0817802a0310da5569ec653 Mon Sep 17 00:00:00 2001 From: Chris Want Date: Wed, 8 Jul 2026 15:25:51 -0600 Subject: [PATCH 12/20] Per advice from Copilot: safer timezone conversions for editing/saving. --- app/controllers/events_controller.rb | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 464d2b231..c4ae57dd6 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -272,17 +272,21 @@ def set_event end def shift_times_to_timezone_for_editing - if (@event&.timezone) - @event.start = @event.start&.in_time_zone(@event.timezone.to_s) - @event.end = @event.end&.in_time_zone(@event.timezone.to_s) - end + tz = @event&.timezone.to_s + return if tz.blank? || ActiveSupport::TimeZone[tz].blank? + + @event.start = @event.start&.in_time_zone(tz) + @event.end = @event.end&.in_time_zone(tz) end def shift_times_to_utc_for_saving - if (@event&.timezone) - @event.start = @event.start&.change(zone: @event.timezone.to_s) - @event.end = @event.end&.change(zone: @event.timezone.to_s) - end + return unless request.format.html? + + tz = @event&.timezone.to_s + return if tz.blank? || ActiveSupport::TimeZone[tz].blank? + + @event.start = @event.start&.change(zone: tz) + @event.end = @event.end&.change(zone: tz) end # Never trust parameters from the scary internet, only allow the white list through. From fbe4a574ea80f292c6c05e085676474e094425a2 Mon Sep 17 00:00:00 2001 From: Chris Want Date: Thu, 9 Jul 2026 08:39:01 -0600 Subject: [PATCH 13/20] Partial advice from Copilot: make the local time stuff more fault tolerant. The specific implementation suggested by Co-pilot gave incorrect values though. --- app/models/event.rb | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/models/event.rb b/app/models/event.rb index ac76e7040..8fe9ff4ff 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -186,11 +186,11 @@ def end_utc end def start_local - start&.in_time_zone(self&.timezone) + set_to_local(start) end def end_local - self.end&.in_time_zone(self&.timezone) + set_to_local(self.end) end def started? @@ -522,8 +522,12 @@ def convert_local_to_utc(datetime) end def set_to_local(datetime) - datetime.asctime.in_time_zone(timezone) - rescue StandardError + # Fallback to UTC when no timezone + return datetime&.in_time_zone unless timezone + + time = datetime&.in_time_zone(timezone) + return time if time + datetime end From 31729bee1256aa10af41b7c743dd9813dc1197e7 Mon Sep 17 00:00:00 2001 From: Chris Want Date: Thu, 9 Jul 2026 08:58:58 -0600 Subject: [PATCH 14/20] Per advice from Copilot: smarter fragment caching. --- app/views/static/home/_upcoming_events.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/static/home/_upcoming_events.html.erb b/app/views/static/home/_upcoming_events.html.erb index acbf2a5ce..9d14ac3ec 100644 --- a/app/views/static/home/_upcoming_events.html.erb +++ b/app/views/static/home/_upcoming_events.html.erb @@ -1,4 +1,4 @@ -<% cache(['home', 'upcoming_events', @events]) do %> +<% cache(['home', 'upcoming_events', I18n.locale, session[:tz], @events]) do %>

From a7e1fb0dbe9850f5be994e15002325550f5df61f Mon Sep 17 00:00:00 2001 From: Chris Want Date: Thu, 9 Jul 2026 09:04:51 -0600 Subject: [PATCH 15/20] Per advice from Copilot: address some accessibitily issues. Switching to
<%= t('events.timezone_controls.controls_legend') %> From 02951774fee6b33ab04a6f874d904da5ca455ae6 Mon Sep 17 00:00:00 2001 From: Chris Want Date: Thu, 9 Jul 2026 09:07:35 -0600 Subject: [PATCH 16/20] Per advice from from Copilot: fix typo in comment. --- app/helpers/events_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index efbed9f3e..7611c06c6 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -175,7 +175,7 @@ def normalized_icu_timezone(name) return nil unless tz_name - # The ICU library we use want's underscores not spaces ... + # The ICU library we use wants underscores not spaces ... tz_name = tz_name.gsub(' ', '_') formatter = ICU::TimeFormatting.create(locale: I18n.locale.to_s, zone: tz_name, From f5809bb00b9e60b0cce1f0b7dc554471753350cf Mon Sep 17 00:00:00 2001 From: Chris Want Date: Thu, 9 Jul 2026 09:25:28 -0600 Subject: [PATCH 17/20] Per advice from Copilot: don't hardcode an ajax URL. Get the url (created with a route helper) from a data property instead. --- app/assets/javascripts/events.js | 2 +- app/views/events/_event_timezone_control.html.erb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/events.js b/app/assets/javascripts/events.js index 8646a1eb1..6aacf0b88 100644 --- a/app/assets/javascripts/events.js +++ b/app/assets/javascripts/events.js @@ -41,7 +41,7 @@ var Events = { // Server renders the various DOM ids that need updating $.ajax({ - url: "/events/event_time_data.json", + url: $('#timezone-controls').data('event-time-data-url'), data: data, context: document.body }).done(function(elements) { diff --git a/app/views/events/_event_timezone_control.html.erb b/app/views/events/_event_timezone_control.html.erb index 624b72693..c4f71cfa9 100644 --- a/app/views/events/_event_timezone_control.html.erb +++ b/app/views/events/_event_timezone_control.html.erb @@ -1,5 +1,5 @@ <%# If browser_timezone is not set, do not render user interface %> -
+

From 7c6ea50342e21702f6535f1241f13059f05129a7 Mon Sep 17 00:00:00 2001 From: Chris Want Date: Thu, 9 Jul 2026 09:33:19 -0600 Subject: [PATCH 18/20] Per advice from Copilot: test blank? rather than falsiness. --- app/views/events/_event_time_div.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/events/_event_time_div.html.erb b/app/views/events/_event_time_div.html.erb index d0da18de8..169e367ca 100644 --- a/app/views/events/_event_time_div.html.erb +++ b/app/views/events/_event_time_div.html.erb @@ -7,7 +7,7 @@

<%= t('events.date') %>: <%= neatly_printed_date_range(event.start_local, event.end_local) %> - <% unless event.timezone %> + <% if event.timezone.blank? %> <%= t('events.no_timezone_provided_gmt_assumed') %> <% end %>

From f340ad04f56a3197dcc5b2ab24c92e0544828091 Mon Sep 17 00:00:00 2001 From: Chris Want Date: Thu, 9 Jul 2026 13:38:25 -0600 Subject: [PATCH 19/20] As per advice from @kennethrioja: make timezone controls off by default. (Except in the test suite.) --- config/tess.example.yml | 2 +- test/config/test_tess.yml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/config/tess.example.yml b/config/tess.example.yml index 95e9f0303..9b3401517 100644 --- a/config/tess.example.yml +++ b/config/tess.example.yml @@ -182,7 +182,7 @@ default: &default # UI sticky_navbar: false # when true, allows navbar (and header_notice if enabled) to stick to the top of the window and shrink when scrolling - timezone_controls: true + timezone_controls: false # Possible features to disable: # biotools, topics, operations, sponsors, fairshare, county, ardc_fields_of_research, diff --git a/test/config/test_tess.yml b/test/config/test_tess.yml index b2511a0fe..86bec728a 100644 --- a/test/config/test_tess.yml +++ b/test/config/test_tess.yml @@ -61,6 +61,7 @@ default: &default bioschemas_testing: true learning_paths: true spaces: true + timezone_controls: true user_ingestion_methods: ["event_csv", "material_csv", "ical", "eventbrite", "tess_event", "zenodo", "bioschemas"] placeholder: collection: 'placeholder-collection.png' From 0081ce332aac2d0ea1e335cd4945b3e8341d306c Mon Sep 17 00:00:00 2001 From: Chris Want Date: Thu, 9 Jul 2026 13:59:41 -0600 Subject: [PATCH 20/20] Fix an issue reported by @kennethrioja: don't show timezone controls when no events on content provider page. --- app/views/common/_associated_events.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/common/_associated_events.html.erb b/app/views/common/_associated_events.html.erb index 1e9da692b..d5c669e5a 100644 --- a/app/views/common/_associated_events.html.erb +++ b/app/views/common/_associated_events.html.erb @@ -30,7 +30,7 @@ <%= link_to "View all #{name.pluralize(shown_count)} & filter", inc_expired_link, class: 'btn btn-xs btn-default' %> <% end %> - <% if TeSS::Config.feature.fetch('timezone_controls') %> + <% if TeSS::Config.feature.fetch('timezone_controls') && total_count > 0 %> <%= render partial: 'events/event_timezone_control' %> <% end %>