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/assets/javascripts/events.js b/app/assets/javascripts/events.js index 281de07fd..8646a1eb1 100644 --- a/app/assets/javascripts/events.js +++ b/app/assets/javascripts/events.js @@ -26,6 +26,46 @@ var Events = { $('.address_content').show() } } + }, + + 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) { + 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') + }) + } + }); } } @@ -40,4 +80,7 @@ $(document).on('change', '.event_cost_basis', function () { $(document).on('ready turbolinks:load', function () { Events.switchCostFields(); Events.switchAddressFields(); + if ($('.time-with-zone').length) { + Events.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/application_controller.rb b/app/controllers/application_controller.rb index 13fc15d1f..76b1d268a 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,18 @@ 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 + + true + end + end diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 7df0b4b41..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 } @@ -226,6 +232,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. @@ -233,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/helpers/events_helper.rb b/app/helpers/events_helper.rb index 4a72a3a08..efbed9f3e 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -148,19 +148,39 @@ 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? - "#{start.strftime(differing.join(' '))} - #{finish.strftime(DATE_STRF)}" + "#{l(start, format: differing.join(' '))} - #{l(finish, format: DATE_STRF)}" end end end + + def normalized_icu_timezone(name) + # Get more standardized name from TZinfo... + 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, + zone: tz_name, + skeleton: '%zzzz') + formatter.format(Time.now) + end + end 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/common/_associated_events.html.erb b/app/views/common/_associated_events.html.erb index 4cfccfb43..1e9da692b 100644 --- a/app/views/common/_associated_events.html.erb +++ b/app/views/common/_associated_events.html.erb @@ -30,5 +30,9 @@ <%= 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') %> + <%= 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 687af7444..49775ff0d 100644 --- a/app/views/events/_event.html.erb +++ b/app/views/events/_event.html.erb @@ -34,7 +34,11 @@ <%= elixir_node_icon %> <% end -%> -

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

+ <% 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/_event_time_div.html.erb b/app/views/events/_event_time_div.html.erb new file mode 100644 index 000000000..d0da18de8 --- /dev/null +++ b/app/views/events/_event_time_div.html.erb @@ -0,0 +1,16 @@ +<% # 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) %> + <% unless event.timezone %> + <%= t('events.no_timezone_provided_gmt_assumed') %> + <% end %> +

+ <%= display_attribute(event, :timezone) {|v| normalized_icu_timezone(v) } %> +
+<% event.timezone = saved_timezone if local_assigns[:timezone] %> 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/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/app/views/events/index.html.erb b/app/views/events/index.html.erb index cab70d890..aeaaa59f1 100644 --- a/app/views/events/index.html.erb +++ b/app/views/events/index.html.erb @@ -41,6 +41,10 @@ <%# ACTUAL RESULTS LIST %> <% if @events.any? %>
+ <% 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 3f036f328..b460456a5 100644 --- a/app/views/events/show.html.erb +++ b/app/views/events/show.html.erb @@ -56,12 +56,19 @@
- -

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

- <%= display_attribute(@event, :timezone) %> + + <% 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 31d223e67..acbf2a5ce 100644 --- a/app/views/static/home/_upcoming_events.html.erb +++ b/app/views/static/home/_upcoming_events.html.erb @@ -1,7 +1,14 @@ <% 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' ) %> +

+ + <% 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/locales/en.yml b/config/locales/en.yml index 727d7ca70..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,13 +632,39 @@ 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' + 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 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 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, 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