diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index abee164b4..1f11a00c6 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -17,3 +17,4 @@ The following people have contributed to the TeSS codebase: * Milo Thurston * Daan van Vugt * Martin Voigt +* Stian Soiland-Reyes diff --git a/Gemfile b/Gemfile index 07b487b1f..985842ab7 100644 --- a/Gemfile +++ b/Gemfile @@ -20,6 +20,7 @@ gem 'eventbrite_sdk' gem 'font-awesome-sass', '~> 4.7.0' # Prefer V4 icon styles gem 'friendly_id' gem 'geocoder' +gem 'wheretz' gem 'gravtastic' gem 'handlebars_assets' gem 'httparty' diff --git a/Gemfile.lock b/Gemfile.lock index cacced0e2..d952e2391 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -845,6 +845,7 @@ GEM websocket-extensions (0.1.5) whenever (1.0.0) chronic (>= 0.6.3) + wheretz (0.0.6) will_paginate (4.0.1) xpath (3.2.0) nokogiri (~> 1.8) @@ -949,6 +950,7 @@ DEPENDENCIES web-console webmock whenever + wheretz will_paginate BUNDLED WITH diff --git a/README.md b/README.md index ee744a7b4..9668a5fc1 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,10 @@ See [here](docs/customization.md) for an overview of how you can customize your See [here](docs/api.md) for details on programmatic access to TeSS via its API. +## Event times and time zones + +See [here](docs/timezones.md) for how event times and time zones are stored, and what ingestors need to do. + ## Multi-space TeSS See [here](docs/spaces.md) for details on configuring a multi-space TeSS. diff --git a/app/assets/javascripts/events.js b/app/assets/javascripts/events.js index 281de07fd..c010ef79a 100644 --- a/app/assets/javascripts/events.js +++ b/app/assets/javascripts/events.js @@ -26,6 +26,36 @@ var Events = { $('.address_content').show() } } + }, + + // Whether the city, country or postcode were edited by hand since the coordinates were set by the address finder + coordinatesStale: false, + timezoneRequest: null, + + // Show the time zone of the event's location if none is selected, or if it differs from the selected one. + // This is only advice: the user can keep their own choice. + suggestTimezone: function () { + var box = $('#timezone-suggestion'); + if (!box.length) { + return; + } + var params = { timezone: $('#event_timezone').val(), country: $('#event_country').val() }; + if (!Events.coordinatesStale) { + params.latitude = $('#event_latitude').val(); + params.longitude = $('#event_longitude').val(); + } + if (Events.timezoneRequest) { + Events.timezoneRequest.abort(); + } + Events.timezoneRequest = $.getJSON(box.data('url'), params, function (data) { + if (data.message) { + box.find('[data-role="timezone-suggestion-message"]').text(data.message); + box.find('[data-role="timezone-suggestion-use"]').text(data.button).data('timezone', data.timezone); + box.show(); + } else { + box.hide(); + } + }); } } @@ -37,7 +67,28 @@ $(document).on('change', '.event_cost_basis', function () { Events.switchCostFields(); }); +$(document).on('change', '#event_timezone, [data-role="online-switch"]', function () { + Events.suggestTimezone(); +}); + +// The address finder sets the coordinates (and triggers this change) together with the address fields +$(document).on('change', '#event_latitude', function () { + Events.coordinatesStale = false; + Events.suggestTimezone(); +}); + +$(document).on('change', '#event_city, #event_country, #event_postcode', function () { + Events.coordinatesStale = true; + Events.suggestTimezone(); +}); + +$(document).on('click', '[data-role="timezone-suggestion-use"]', function () { + $('#event_timezone').val($(this).data('timezone')).trigger('change'); +}); + $(document).on('ready turbolinks:load', function () { Events.switchCostFields(); Events.switchAddressFields(); + Events.coordinatesStale = false; + Events.suggestTimezone(); }); diff --git a/app/assets/javascripts/map_search.js b/app/assets/javascripts/map_search.js index 812bed39a..b2030dd75 100644 --- a/app/assets/javascripts/map_search.js +++ b/app/assets/javascripts/map_search.js @@ -36,7 +36,8 @@ var MapSearch = { }); if(country != null) $('#event_country').val(country); if(postcode != null) $('#event_postcode').val(postcode); - + $('#event_latitude').trigger('change'); + MapSearch.placeMarker(marker_icon); } }); diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index bdfe153fb..3f1e36ca4 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -38,12 +38,12 @@ def index def calendar # set a higher limit so we ensure to have enough results to fill a month params[:per_page] ||= 200 - @start_date = Date.parse(params.fetch(:start_date, Date.today.to_s)).beginning_of_month + @start_date = Date.parse(params.fetch(:start_date, Date.current.to_s)).beginning_of_month set_params # override @facet_params to get only events relevant for the current month view - @facet_params[:running_during] = "#{Date.today.beginning_of_day}/#{@start_date + 1.month}" + @facet_params[:running_during] = "#{Date.current.beginning_of_day}/#{@start_date + 1.month}" fetch_resources events_set = @events @@ -94,8 +94,19 @@ def preview # GET /events/new def new authorize Event - @event = Event.new(start: DateTime.now.change(hour: 9), - end: DateTime.now.change(hour: 17)) + @event = Event.new(start: Time.zone.now.change(hour: 9), + end: Time.zone.now.change(hour: 17)) + end + + # GET /events/timezone_suggestion?latitude=..&longitude=..&country=..&timezone=.. + # The time zone of the location entered in the event form, and a message if it differs from the selected one. + def timezone_suggestion + authorize Event, :new? + suggestion = TimezoneLookup.find(latitude: params[:latitude], longitude: params[:longitude], + country: params[:country]) + message = timezone_suggestion_message(suggestion, params[:timezone].presence) + + render json: { timezone: suggestion, message:, button: (t('events.timezone_suggestion.use', suggestion:) if message) } end # GET /events/1/clone @@ -228,6 +239,16 @@ def redirect private + def timezone_suggestion_message(suggestion, selected) + return nil if suggestion.nil? || (selected && TimezoneLookup.same_offsets?(selected, suggestion)) + + if selected + t('events.timezone_suggestion.mismatch', suggestion:, selected:) + else + t('events.timezone_suggestion.blank', suggestion:) + end + end + # Use callbacks to share common setup or constraints between actions. def set_event @event = Event.friendly.find(params[:id]) diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 4a72a3a08..14e9595f4 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -13,8 +13,11 @@ def google_calendar_export_url(event) dates = if event.all_day? # Need to add 1 day for all day events apparently "#{event.start.strftime('%Y%m%d')}/#{event.end.tomorrow.strftime('%Y%m%d')}" - else + elsif event.known_time_zone "#{event.start_utc.strftime('%Y%m%dT%H%M00Z')}/#{event.end_utc.strftime('%Y%m%dT%H%M00Z')}" + else + # Time zone unknown: local times, which Google Calendar takes in the user's time zone, rather than UTC + "#{event.start.strftime('%Y%m%dT%H%M00')}/#{event.end.strftime('%Y%m%dT%H%M00')}" end location = if event.online? @@ -26,7 +29,7 @@ def google_calendar_export_url(event) event_params = { text: event.title, dates:, - ctz: event.timezone, + ctz: event.known_time_zone&.tzinfo&.identifier, # IANA name, e.g. Australia/Perth rather than Perth details: "#{event_url(event)}", location:, sf: true, diff --git a/app/models/event.rb b/app/models/event.rb index 030b25f10..a3522d5eb 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -30,6 +30,10 @@ class Event < ApplicationRecord before_save :check_country_name before_save :set_default_times before_save :geocoding_cache_lookup, if: :address_will_change? + before_save -> { self.timezone_inferred = false }, if: :will_save_change_to_timezone? + # Not for date-only events with default times: their floating local times show on the right day anywhere + before_save :clear_inferred_timezone, if: -> { timezone_inferred? && default_times? } + before_save :timezone_from_location, if: -> { timezone.blank? && !default_times? } after_save :enqueue_geocoding_worker, if: :address_changed? if TeSS::Config.solr_enabled @@ -175,6 +179,8 @@ class Event < ApplicationRecord COUNTRY_SYNONYMS = JSON.parse(File.read(File.join(Rails.root, 'config', 'data', 'country_synonyms.json'))) + # Microseconds added to default times (see set_default_times) + DEFAULT_TIME_MARKER = 1 NOMINATIM_DELAY = 1.minute NOMINATIM_MAX_ATTEMPTS = 3 @@ -194,6 +200,28 @@ def start_local set_to_local start end + # A start or end time as an ISO 8601 date-time with the UTC offset of the event's time zone on that date, e.g. + # "2015-11-23T10:16:33-05:00". Times are stored as local (wall clock) time, so without a known time zone (e.g. + # many online events) it is given without an offset, as local time, rather than as UTC. + def local_iso8601(time) + return nil if time.blank? + + wall_clock = time.strftime('%Y-%m-%dT%H:%M:%S') + zone = known_time_zone + zone ? zone.parse(wall_clock).iso8601 : wall_clock + end + + # Whether the start time is the default (09:00) that set_default_times gives events with only a date, rather than + # a time given by the user or the source + def default_times? + start&.usec == DEFAULT_TIME_MARKER + end + + # The event's time zone, or nil if it has none or it is not recognised + def known_time_zone + ActiveSupport::TimeZone[timezone] if timezone.present? + end + def end_local set_to_local self.end end @@ -259,9 +287,14 @@ def to_ical_event if all_day? ical_event.dtstart = Icalendar::Values::Date.new(start, tzid: 'UTC') unless start.blank? ical_event.dtend = Icalendar::Values::Date.new(self.end.tomorrow, tzid: 'UTC') unless self.end.blank? - else + elsif known_time_zone ical_event.dtstart = Icalendar::Values::DateTime.new(start_utc, tzid: 'UTC') unless start.blank? ical_event.dtend = Icalendar::Values::DateTime.new(end_utc, tzid: 'UTC') unless self.end.blank? + else + # Time zone unknown: floating (local) times, e.g. DTSTART:20261105T090000, which calendars show at the same + # clock time and day in their own time zone, assuming people are near the event, rather than as UTC + ical_event.dtstart = Icalendar::Values::DateTime.new(start.strftime('%Y%m%dT%H%M%S')) + ical_event.dtend = Icalendar::Values::DateTime.new(self.end.strftime('%Y%m%dT%H%M%S')) end end @@ -289,10 +322,12 @@ def all_day? def set_default_times return unless start - self.start = start + 9.hours if start.hour == 0 # hour set to 0 if not otherwise defined... + # Hour set to 0 if not otherwise defined, e.g. only a date was given. The default times are marked with 1 + # microsecond (09:00:00.000001), which is not shown, so that they can be told apart (see default_times?) + self.start = (start + 9.hours).change(usec: DEFAULT_TIME_MARKER) if start.hour.zero? if self.end - self.end = self.end + 17.hours if self.end.hour == 0 # hour set to 0 if not otherwise defined... + self.end = (self.end + 17.hours).change(usec: DEFAULT_TIME_MARKER) if self.end.hour.zero? elsif online? self.end = start + 1.hour else @@ -385,6 +420,20 @@ def address_changed? ADDRESS_FIELDS.any? { |field| previous_changes.keys.include?(field.to_s) } end + # Set a missing time zone from the event's coordinates (once geocoded) or, failing that, its country. Online + # events often have neither, and keep no time zone. Marked as inferred until the time zone is changed otherwise, + # e.g. by the user. Not done for events with the default 09:00-17:00 times (see default_times?), as combining two + # guesses would pin them to a time and day that may not apply. + def timezone_from_location + self.timezone = TimezoneLookup.find(latitude:, longitude:, country:) + self.timezone_inferred = timezone.present? + end + + def clear_inferred_timezone + self.timezone = nil + self.timezone_inferred = false + end + # Check the Redis cache for coordinates def geocoding_cache_lookup location = address diff --git a/app/serializers/event_serializer.rb b/app/serializers/event_serializer.rb index 687e857ae..ce1b8bb7a 100644 --- a/app/serializers/event_serializer.rb +++ b/app/serializers/event_serializer.rb @@ -3,7 +3,7 @@ class EventSerializer < ApplicationSerializer :keywords, :event_types, :scientific_topics, :operations, :fields, :external_resources, - :start, :end, :duration, :timezone, + :start, :end, :duration, :timezone, :timezone_inferred, :organizer, :sponsors, :contact, :host_institutions, @@ -28,6 +28,15 @@ def online object.online? || object.hybrid? end + # Local time with the offset of the event's time zone, not the stored wall clock time as UTC + def start + object.local_iso8601(object.start) + end + + def end + object.local_iso8601(object.end) + end + def presence object.presence.to_s end diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index 7dbb92a6b..d423deb8f 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -48,6 +48,11 @@ <%= f.input :timezone, as: :time_zone, field_lock: true, priority: priority_time_zones, input_html: { class: 'js-select2', title: t('events.hints.timezone') } %> + <%= f.input :duration, as: :string, input_html: { title: t('events.hints.duration') } %> diff --git a/app/views/events/show.html.erb b/app/views/events/show.html.erb index e263ed2b8..4f23e630d 100644 --- a/app/views/events/show.html.erb +++ b/app/views/events/show.html.erb @@ -63,7 +63,7 @@ Date: <%= neatly_printed_date_range(@event.start, @event.end) %>

- <%= display_attribute(@event, :timezone) %> + <%= display_attribute(@event, :timezone) { |tz| @event.timezone_inferred? ? t('events.timezone_inferred', timezone: tz) : tz } %> <%= 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 2cb64b934..6428a3f03 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -634,6 +634,13 @@ en: controller: "The data controller for %{site_name} is The University of Manchester." post: "The TeSS Team
Room 2.86 c/o School of Computer Science,
Kilburn Building,
Oxford Road,
Manchester,
M13 9PL" events: + timezone_inferred: "%{timezone} (inferred)" + timezone_suggestion: + blank: 'No time zone is selected. Based on the location, it will be set to %{suggestion}.' + mismatch: >- + The location is in the %{suggestion} time zone, not %{selected}. Please check that the start and end times + are in the selected time zone. + use: 'Use %{suggestion}' hints: address: 'Physical location of the event if its being conducted Face-to-face.' capacity: > diff --git a/config/routes.rb b/config/routes.rb index 627892161..86118c730 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -65,6 +65,7 @@ collection do get :count get :calendar, format: %i[js html] + get :timezone_suggestion post :preview end member do diff --git a/db/migrate/20261022230000_add_timezone_inferred_to_events.rb b/db/migrate/20261022230000_add_timezone_inferred_to_events.rb new file mode 100644 index 000000000..ace78f001 --- /dev/null +++ b/db/migrate/20261022230000_add_timezone_inferred_to_events.rb @@ -0,0 +1,5 @@ +class AddTimezoneInferredToEvents < ActiveRecord::Migration[8.1] + def change + add_column :events, :timezone_inferred, :boolean, default: false, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index ed474f683..93f658b31 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_07_29_120000) do +ActiveRecord::Schema[8.1].define(version: 2026_10_22_230000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -228,6 +228,7 @@ t.string "target_audience", default: [], array: true t.text "tech_requirements" t.string "timezone" + t.boolean "timezone_inferred", default: false, null: false t.string "title" t.integer "trainer_count" t.datetime "updated_at", null: false diff --git a/docs/docker.md b/docs/docker.md index 7fcb5c1f6..91f4abdc5 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -147,3 +147,8 @@ Precompile the assets, necessary if any CSS/JS/images are changed after building Reindex Solr: docker compose -f docker-compose-prod.yml exec app bundle exec rake tess:reindex + +Set the time zone of events that have none from their location (coordinates or country), e.g. after upgrading. +New and edited events get one automatically; these are shown as "(inferred)": + + docker compose -f docker-compose-prod.yml exec app bundle exec rake tess:infer_event_timezones diff --git a/docs/timezones.md b/docs/timezones.md new file mode 100644 index 000000000..1aa0a1c71 --- /dev/null +++ b/docs/timezones.md @@ -0,0 +1,43 @@ +# Event times and time zones + +## Storage + +An event's `start` and `end` are stored as **local (wall clock) time**, with its time zone in `timezone`. The +columns are UTC `datetime`s, so an event at 09:00–17:00 in Buenos Aires is stored as `09:00 UTC` with +`timezone: 'Buenos Aires'`: the stored value is not the actual UTC time. + +- `timezone` is optional: blank means unknown, not UTC. Its values are Rails time zone names (e.g. `Buenos Aires`, + `London`), the keys of `ActiveSupport::TimeZone::MAPPING`. +- `Event#start_utc` / `#end_utc` give the actual UTC time, `#start_local` the time in the event's time zone, and + `#local_iso8601(time)` ISO 8601 with the offset on that date (`2026-10-22T09:00:00-03:00`), or without an offset + if the time zone is unknown. These are used by the iCal and Google Calendar exports, JSON-API and Bioschemas. +- Without a time zone, exports give floating local times (`DTSTART:20261105T090000`), which calendars show at the + same clock time in their own time zone, so on the same day for people near the event. +- Nothing should depend on the server's time zone: use `Time.zone.now` and `Date.current`, not `Time.now`, + `DateTime.now` or `Date.today`. + +## Default times + +Events given only dates (start and end at midnight) get 09:00–17:00, or an hour for online events without an end +(`Event#set_default_times`). These default times are marked with **1 microsecond**, e.g. `09:00:00.000001`, which +is not shown anywhere, so that they can be told apart from real 09:00–17:00 events: `Event#default_times?`. +Start and end both get the marker, so durations stay whole. + +## Inferred time zones + +A missing time zone is looked up from the event's coordinates, or a country with a single time zone +(`TimezoneLookup`), when the event is saved, and marked with `timezone_inferred` until it is changed. This is not +done for events with default times, as a guessed time zone on top of guessed times would pin them to a time and +day that may not apply. `rake tess:infer_event_timezones` does the same for existing events. + +## Ingestors + +- Assign the **local time of the source**, and set `timezone` if the source gives one. Do not assign a time with + an offset (e.g. `Time` from `.to_time`, or `"2026-10-22T09:00:00+01:00"`): Rails converts it to the actual UTC + time, which TeSS would then show shifted by the offset. +- For iCalendar `DTSTART`/`DTEND`, use `Ingestor#ical_local_time`, with `timezone` from the `TZID` parameter (`UTC` + for `...Z` times). +- A date without a time can be assigned as a `Date` (or midnight): the event then gets the default times. +- In tests, check `start_utc` or `local_iso8601(start)` rather than the stored column, and expect the marker for + default times (`09:00:00.000001`). `mock_timezone` runs a test in another server time zone, which should not + change the result. diff --git a/lib/bioschemas/course_instance_generator.rb b/lib/bioschemas/course_instance_generator.rb index aad08b67e..5e34817c9 100644 --- a/lib/bioschemas/course_instance_generator.rb +++ b/lib/bioschemas/course_instance_generator.rb @@ -8,8 +8,8 @@ def self.bioschemas_profile 'https://bioschemas.org/profiles/CourseInstance/1.0-RELEASE' end - property :startDate, :start - property :endDate, :end + property :startDate, -> (event) { local_time(event, event.start) } + property :endDate, -> (event) { local_time(event, event.end) } property :organizer, -> (event) { { '@type' => 'Organization', name: event.organizer } if event.organizer.present? } property :location, -> (event) { address(event) }, condition: -> (event) { event.venue.present? || event.city.present? || event.county.present? || diff --git a/lib/bioschemas/event_generator.rb b/lib/bioschemas/event_generator.rb index 3722480a7..892bc935d 100644 --- a/lib/bioschemas/event_generator.rb +++ b/lib/bioschemas/event_generator.rb @@ -13,8 +13,8 @@ def self.bioschemas_profile property :url, :url property :description, :description property :keywords, :keywords - property :startDate, :start - property :endDate, :end + property :startDate, -> (event) { local_time(event, event.start) } + property :endDate, -> (event) { local_time(event, event.end) } property :organizer, -> (event) { { '@type' => 'Organization', name: event.organizer } if event.organizer.present? } property :location, -> (event) { address(event) }, condition: -> (event) { event.venue.present? || event.city.present? || event.county.present? || diff --git a/lib/bioschemas/generator.rb b/lib/bioschemas/generator.rb index 046bba106..a5d743743 100644 --- a/lib/bioschemas/generator.rb +++ b/lib/bioschemas/generator.rb @@ -79,6 +79,11 @@ def self.term(term) } end + # An event's start or end time in ISO 8601, with the offset of the event's time zone (see Event#local_iso8601) + def self.local_time(event, time) + event.local_iso8601(time) + end + def self.person(person) p = { "@type" => "Person", diff --git a/lib/ingestors/heptraining/gray_scott_ingestor.rb b/lib/ingestors/heptraining/gray_scott_ingestor.rb index e4525b3fe..ebc2f532c 100644 --- a/lib/ingestors/heptraining/gray_scott_ingestor.rb +++ b/lib/ingestors/heptraining/gray_scott_ingestor.rb @@ -41,10 +41,10 @@ def process_calevent(calevent, url) html_description = html.css('.paragraphStyle').text.to_s.strip event.description = html_description.empty? ? calevent.description.to_s : html_description - event.end = calevent.dtend&.to_time&.utc + event.end = ical_local_time(calevent.dtend) unless calevent.dtstart.nil? dtstart = calevent.dtstart - event.start = dtstart&.to_time&.utc + event.start = ical_local_time(dtstart) tzid = dtstart.ical_params['tzid'] event.timezone = tzid.first.to_s if !tzid.nil? && tzid.size.positive? end diff --git a/lib/ingestors/ical_ingestor.rb b/lib/ingestors/ical_ingestor.rb index e31ef95de..865ef5390 100644 --- a/lib/ingestors/ical_ingestor.rb +++ b/lib/ingestors/ical_ingestor.rb @@ -51,10 +51,10 @@ def process_event(calevent) event.title = calevent.summary&.to_s event.description = process_description(calevent.description) - event.end = calevent.dtend&.to_time + event.end = ical_local_time(calevent.dtend) unless calevent.dtstart.nil? dtstart = calevent.dtstart - event.start = dtstart&.to_time + event.start = ical_local_time(dtstart) tzid = dtstart.ical_params['tzid'] event.timezone = tzid.first.to_s if tzid.present? end diff --git a/lib/ingestors/indico_ingestor.rb b/lib/ingestors/indico_ingestor.rb index 66fa4fcaa..749678607 100644 --- a/lib/ingestors/indico_ingestor.rb +++ b/lib/ingestors/indico_ingestor.rb @@ -110,8 +110,8 @@ def assign_basic_info(event, calevent) # Assigns to event: start, end, timezone. def assign_time_info(event, calevent) - event.start = calevent.dtstart&.to_time unless calevent.dtstart.nil? - event.end = calevent.dtend&.to_time unless calevent.dtend.nil? + event.start = ical_local_time(calevent.dtstart) + event.end = ical_local_time(calevent.dtend) event.timezone = get_tzid(calevent.dtstart) end diff --git a/lib/ingestors/ingestor.rb b/lib/ingestors/ingestor.rb index 291412905..ac755e245 100644 --- a/lib/ingestors/ingestor.rb +++ b/lib/ingestors/ingestor.rb @@ -64,6 +64,16 @@ def stats_summary(type) summary end + # The local (wall clock) time of an iCalendar date or date-time, e.g. 09:45 for + # DTSTART;TZID=Australia/Perth:20220211T094500, as TeSS stores event times as local time, with the time zone + # in the event's +timezone+. A date (all-day event) gives midnight, whatever the server's time zone. + def ical_local_time(value) + return nil if value.nil? + + time = value.respond_to?(:hour) ? [value.hour, value.min, value.sec] : [] + Time.utc(value.year, value.month, value.day, *time) + end + def open_url(url, raise: false, token: nil) options = { redirect: false, # We're doing redirects manually below, since open-uri can't handle http -> https redirection diff --git a/lib/tasks/tess.rake b/lib/tasks/tess.rake index 665865ac3..8b8975d1a 100644 --- a/lib/tasks/tess.rake +++ b/lib/tasks/tess.rake @@ -206,6 +206,41 @@ namespace :tess do puts 'Task: check_timezones - finished.' end + desc 'Set missing event time zones from the location (coordinates or country), marked as inferred' + task infer_event_timezones: :environment do + events = Event.where(timezone: [nil, '']) + total = events.count + inferred = 0 + events.find_each do |event| + next if event.default_times? # Date-only events keep floating local times + + timezone = TimezoneLookup.find(latitude: event.latitude, longitude: event.longitude, country: event.country) + next unless timezone + + # Not an edit of the event: keep updated_at and skip callbacks + event.update_columns(timezone:, timezone_inferred: true) + event.solr_index if TeSS::Config.solr_enabled + inferred += 1 + end + puts "Task: infer_event_timezones - inferred the time zone of #{inferred} of #{total} events without one" + end + + desc 'Report events ingested from iCalendar with a time zone, which were stored in UTC rather than local time' + task report_ical_event_times: :environment do + # Before the fix, these ingestors stored DTSTART;TZID=... times converted to UTC, while TeSS treats event times + # as local time in the event's timezone, so these events show and export shifted by their UTC offset. UTC (...Z) + # times have timezone UTC and were not affected. Upcoming events are corrected when their source is ingested again. + zoned = Event.where.not(timezone: [nil, '', 'UTC']) + Source.where(method: %w[ical indico gray_scott_event]).includes(:content_provider).find_each do |source| + events = zoned.where(last_scraped_by_id: source.id) + # Events scraped before last_scraped_by was recorded, from the same content provider + older = zoned.where(last_scraped_by_id: nil, scraper_record: true, content_provider_id: source.content_provider_id) + puts "Source #{source.id} (#{source.method}) #{source.url} - #{source.content_provider&.title}: " \ + "#{events.not_finished.count} upcoming, #{events.finished.count} past; " \ + "#{older.not_finished.count} upcoming, #{older.finished.count} past possibly from this source" + end + end + desc 'Fetch and convert SPDX licenses from GitHub' task fetch_spdx: :environment do old_licenses = YAML.load(File.read(File.join(Rails.root, 'config', 'dictionaries', 'licences_old.yml'))) diff --git a/lib/timezone_lookup.rb b/lib/timezone_lookup.rb new file mode 100644 index 000000000..2a94a3dc5 --- /dev/null +++ b/lib/timezone_lookup.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +# Finds the time zone of a place, as one of the time zone names TeSS accepts (the keys of +# ActiveSupport::TimeZone::MAPPING, e.g. "Eastern Time (US & Canada)"). +# +# Coordinates are looked up offline in the timezone-boundary-builder polygons bundled with the wheretz gem. Without +# coordinates, a country with a single time zone gives that zone. +module TimezoneLookup + # Sample times over a year, to compare the UTC offsets (including daylight saving time) of two time zones + SAMPLES = (0..23).map { |i| Time.utc(Time.now.year, 1, 1) + (i * 365.25 / 24).days }.freeze + + # Returns a TeSS time zone name, or nil if none can be found. + def self.find(latitude: nil, longitude: nil, country: nil) + if latitude.present? && longitude.present? + zone = zone_at(latitude.to_f, longitude.to_f) + return rails_name(zone, latitude.to_f, longitude.to_f) if zone + end + + zone = country_zone(country) + rails_name(zone) if zone + end + + # Whether two TeSS time zone names (or IANA identifiers) have the same UTC offsets over the year, e.g. "London" + # and "Edinburgh". False if either is unknown. + def self.same_offsets?(zone_a, zone_b) + ids = [zone_a, zone_b].map { |zone| ActiveSupport::TimeZone[zone.to_s]&.tzinfo&.identifier } + return false if ids.any?(&:nil?) + + offsets(ids[0]) == offsets(ids[1]) + end + + # The IANA time zone at a point, or nil if there is none or the point is on a border between time zones. + def self.zone_at(latitude, longitude) + zone = WhereTZ.lookup(latitude, longitude) + zone.is_a?(String) ? zone : nil + rescue ArgumentError + nil + end + + # The main IANA time zone of a country (name or ISO 3166 code), if all its time zones have the same UTC offsets + # (e.g. Germany's Europe/Berlin and Europe/Busingen). + def self.country_zone(country) + return nil if country.blank? + + iso = ISO3166::Country[country] || ISO3166::Country.find_country_by_any_name(country) + return nil unless iso + + zones = TZInfo::Country.get(iso.alpha2).zone_identifiers + zones.first if zones.map { |zone| offsets(zone) }.uniq.one? + rescue TZInfo::InvalidCountryCode + nil + end + + # The Rails time zone name for an IANA time zone: its own name if Rails has one, otherwise the Rails time zone + # with the same UTC offsets over the year that is nearest to the given point (or to the zone's main city). + def self.rails_name(zone, latitude = nil, longitude = nil) + canonical = TZInfo::Timezone.get(zone).canonical_identifier + mapping = ActiveSupport::TimeZone::MAPPING + name = [zone, canonical].lazy.map { |id| mapping.key?(id) ? id : named_after_city(id) }.find(&:itself) + name || nearest_with_same_offsets(canonical, *(latitude && longitude ? [latitude, longitude] : locations[canonical])) + rescue TZInfo::InvalidTimezoneIdentifier + nil + end + + # The Rails time zone with the same UTC offsets over the year as an IANA time zone that is nearest to a point + def self.nearest_with_same_offsets(zone, latitude = nil, longitude = nil) + mapping = ActiveSupport::TimeZone::MAPPING + candidates = mapping.select { |_, id| offsets(id) == offsets(zone) }.keys + return candidates.first if latitude.nil? || longitude.nil? + + candidates.min_by do |candidate| + location = locations[TZInfo::Timezone.get(mapping[candidate]).canonical_identifier] + location ? distance(latitude, longitude, *location) : Float::INFINITY + end + end + + # The Rails name for an IANA time zone, preferring the one named after its city when there are several + # (e.g. "London" rather than "Edinburgh" for Europe/London). + def self.named_after_city(zone) + names = ActiveSupport::TimeZone::MAPPING.select { |_, id| id == zone }.keys + city = zone.split('/').last.tr('_', ' ') + names.find { |name| name == city } || names.first + end + + def self.offsets(zone) + @offsets ||= {} + @offsets[zone] ||= begin + tz = TZInfo::Timezone.get(zone) + SAMPLES.map { |time| tz.period_for_utc(time).observed_utc_offset } + end + end + + # The location of the main city of each IANA time zone, from tzinfo's zone.tab + def self.locations + @locations ||= TZInfo::Country.all.flat_map(&:zone_info).to_h do |zone| + [zone.identifier, [zone.latitude.to_f, zone.longitude.to_f]] + end + end + + # Angle between two points on a sphere (relative distance is all that is needed) + def self.distance(lat1, lon1, lat2, lon2) + rad = Math::PI / 180 + cos = (Math.sin(lat1 * rad) * Math.sin(lat2 * rad)) + + (Math.cos(lat1 * rad) * Math.cos(lat2 * rad) * Math.cos((lon1 - lon2) * rad)) + Math.acos(cos.clamp(-1, 1)) + end +end diff --git a/public/api/definitions/tess.yml b/public/api/definitions/tess.yml index 40255dd59..5a8c55888 100644 --- a/public/api/definitions/tess.yml +++ b/public/api/definitions/tess.yml @@ -946,12 +946,14 @@ components: start: type: string nullable: true - description: The date and time at which the event starts. + description: The date and time the event starts, in ISO 8601 with the UTC offset of the event's timezone on that date, + e.g. 2026-10-22T09:00:00-03:00, or without an offset (local time) if the timezone is unknown. format: date end: type: string nullable: true - description: The date and time at which the event ends. + description: The date and time the event ends, in ISO 8601 with the UTC offset of the event's timezone on that date, + e.g. 2026-10-22T09:00:00-03:00, or without an offset (local time) if the timezone is unknown. format: date sponsor: type: string @@ -1091,6 +1093,10 @@ components: type: string nullable: true description: The timezone in which the event takes place. e.g. Europe/Brussels + timezone-inferred: + type: boolean + description: Whether the timezone was inferred from the event's location (coordinates or country), rather + than given by the user or the source of the event. sponsors: type: array description: Who sponsored, or funded the event. diff --git a/test/controllers/events_controller_test.rb b/test/controllers/events_controller_test.rb index 984f6a0a6..2313bbc48 100644 --- a/test/controllers/events_controller_test.rb +++ b/test/controllers/events_controller_test.rb @@ -149,6 +149,18 @@ class EventsControllerTest < ActionController::TestCase assert_response :success end + test 'should default new event to 09:00-17:00, whatever the server time zone' do + sign_in users(:regular_user) + %w[Asia/Tokyo America/New_York].each do |tz| + mock_timezone(tz) + get :new + assert_equal [9, 0], [assigns(:event).start.hour, assigns(:event).start.min], tz + assert_equal 17, assigns(:event).end.hour, tz + ensure + reset_timezone + end + end + test 'should not get new page for basic users' do sign_in users(:basic_user) get :new @@ -1719,4 +1731,87 @@ class EventsControllerTest < ActionController::TestCase assert_select '#origin-info a[href=?]', uri end + + test 'should show inferred timezone' do + @event.update_columns(timezone: 'London', timezone_inferred: true) + get :show, params: { id: @event } + assert_response :success + assert_select 'p.timezone', text: /London \(inferred\)/ + end + + test 'should show timezone chosen by user without inferred' do + @event.update_columns(timezone: 'London', timezone_inferred: false) + get :show, params: { id: @event } + assert_response :success + assert_select 'p.timezone', text: /London/ + assert_select 'p.timezone', text: /inferred/, count: 0 + end + + test 'should suggest timezone when none is selected' do + sign_in users(:regular_user) + get :timezone_suggestion, params: { latitude: '-37.81', longitude: '144.96', country: 'AU', timezone: '' } + assert_response :success + json = JSON.parse(response.body) + assert_equal 'Melbourne', json['timezone'] + assert_includes json['message'], 'Melbourne' + assert_equal 'Use Melbourne', json['button'] + end + + test 'should warn when selected timezone differs from location' do + sign_in users(:regular_user) + get :timezone_suggestion, params: { latitude: '-37.81', longitude: '144.96', country: 'AU', timezone: 'London' } + json = JSON.parse(response.body) + assert_equal 'Melbourne', json['timezone'] + assert_includes json['message'], 'not London' + end + + test 'should not warn when selected timezone has the same offsets as the location' do + sign_in users(:regular_user) + get :timezone_suggestion, params: { latitude: '55.95', longitude: '-3.19', country: 'GB', timezone: 'Edinburgh' } + json = JSON.parse(response.body) + assert_equal 'London', json['timezone'] + assert_nil json['message'] + end + + test 'should suggest timezone from country without coordinates' do + sign_in users(:regular_user) + get :timezone_suggestion, params: { country: 'GB', timezone: '' } + assert_equal 'London', JSON.parse(response.body)['timezone'] + + get :timezone_suggestion, params: { country: 'US', timezone: '' } + json = JSON.parse(response.body) + assert_nil json['timezone'] + assert_nil json['message'] + end + + test 'should not suggest timezone for basic users' do + sign_in users(:basic_user) + get :timezone_suggestion, params: { country: 'GB' } + assert_response :forbidden + end + + test 'should give start and end with the offset of the event timezone in json-api' do + @event.update_columns(timezone: 'Buenos Aires', start: Time.utc(2026, 10, 22, 9), end: Time.utc(2026, 10, 22, 17)) + + get :show, params: { id: @event, format: :json_api } + attributes = JSON.parse(response.body)['data']['attributes'] + assert_equal '2026-10-22T09:00:00-03:00', attributes['start'] + assert_equal '2026-10-22T17:00:00-03:00', attributes['end'] + end + + test 'should give start and end without offset in json-api when timezone is unknown' do + @event.update_columns(timezone: nil, start: Time.utc(2026, 10, 22, 9), end: Time.utc(2026, 10, 22, 17)) + + get :show, params: { id: @event, format: :json_api } + assert_equal '2026-10-22T09:00:00', JSON.parse(response.body)['data']['attributes']['start'] + end + + test 'should leave start and end in the legacy json api unchanged' do + @event.update_columns(timezone: 'Buenos Aires', start: Time.utc(2026, 10, 22, 9), end: Time.utc(2026, 10, 22, 17)) + + get :show, params: { id: @event, format: :json } + json = JSON.parse(response.body) + assert_equal '2026-10-22T09:00:00.000Z', json['start'] + assert_nil json['timezone_inferred'] + end end diff --git a/test/helpers/events_helper_test.rb b/test/helpers/events_helper_test.rb index 5f3d8d4c0..aa9f9bb8a 100644 --- a/test/helpers/events_helper_test.rb +++ b/test/helpers/events_helper_test.rb @@ -55,4 +55,20 @@ class EventsHelperTest < ActionView::TestCase assert_equal 'No date given', neatly_printed_date_range(nil, '') assert_equal 'No start date', neatly_printed_date_range(nil, DateTime.new(2024, 5, 16, 17)) end + + test 'google calendar export in UTC with the IANA time zone when the time zone is known' do + event = events(:one) + event.assign_attributes(timezone: 'Perth', start: Time.utc(2022, 2, 11, 9, 45), end: Time.utc(2022, 2, 11, 12, 50)) + params = Rack::Utils.parse_query(URI.parse(google_calendar_export_url(event)).query) + assert_equal '20220211T014500Z/20220211T045000Z', params['dates'] + assert_equal 'Australia/Perth', params['ctz'] + end + + test 'google calendar export in local time when the time zone is unknown' do + event = events(:one) + event.assign_attributes(timezone: nil, start: Time.utc(2026, 11, 5, 9), end: Time.utc(2026, 11, 5, 17)) + params = Rack::Utils.parse_query(URI.parse(google_calendar_export_url(event)).query) + assert_equal '20261105T090000/20261105T170000', params['dates'] + assert_nil params['ctz'] + end end diff --git a/test/integration/bioschemas_test.rb b/test/integration/bioschemas_test.rb index 9b7a52c2c..2948e0ea6 100644 --- a/test/integration/bioschemas_test.rb +++ b/test/integration/bioschemas_test.rb @@ -33,8 +33,8 @@ class BioschemasTest < ActionDispatch::IntegrationTest assert_equal ['an event to test calendar exports'], props['alternateName'] assert_equal ['http://microsoft.com'], props['url'] assert_equal ['Keyword'], props['keywords'] - assert_equal ['2021-09-20 23:15:00 UTC'], props['startDate'] - assert_equal ['2021-09-21 01:00:00 UTC'], props['endDate'] + assert_equal ['2021-09-20T23:15:00+10:00'], props['startDate'] + assert_equal ['2021-09-21T01:00:00+10:00'], props['endDate'] # Address q = RDF::Query.new do @@ -125,8 +125,8 @@ class BioschemasTest < ActionDispatch::IntegrationTest assert_equal ['Learn stuff', 'Get a job', 'Get paid'].sort, course_props['teaches'].sort, 'Should split the markdown into an array' assert_equal ['de'].sort, course_props['inLanguage'] - assert_equal ['2015-08-23 10:16:33 UTC'], course_instance_props['startDate'] - assert_equal ['2015-08-24 18:07:46 UTC'], course_instance_props['endDate'] + assert_equal ['2015-08-23T10:16:33Z'], course_instance_props['startDate'] + assert_equal ['2015-08-24T18:07:46Z'], course_instance_props['endDate'] # Audience q = RDF::Query.new do @@ -290,6 +290,24 @@ class BioschemasTest < ActionDispatch::IntegrationTest assert_equal ["The meaning of life\n * item1 \n * item2\n"], course_props['teaches'] end + test 'event dates in ISO 8601 with the offset of the event timezone on that date' do + event = events(:training_event) + event.update_columns(timezone: 'Eastern Time (US & Canada)', start: Time.utc(2021, 7, 1, 9), end: Time.utc(2021, 12, 1, 17)) + + props = Bioschemas::EventGenerator.new(event).generate + assert_equal '2021-07-01T09:00:00-04:00', props[:startDate], 'Should use daylight saving time in summer' + assert_equal '2021-12-01T17:00:00-05:00', props[:endDate] + end + + test 'event dates without offset when timezone is unknown' do + event = events(:training_event) + event.update_columns(timezone: nil, start: Time.utc(2021, 7, 1, 9), end: Time.utc(2021, 7, 1, 17)) + + props = Bioschemas::EventGenerator.new(event).generate + assert_equal '2021-07-01T09:00:00', props[:startDate] + assert_equal '2021-07-01T17:00:00', props[:endDate] + end + test 'Bioschemas on event index page' do event = events(:training_event) course = events(:course_event) diff --git a/test/models/event_test.rb b/test/models/event_test.rb index 55ae09150..b03589063 100644 --- a/test/models/event_test.rb +++ b/test/models/event_test.rb @@ -387,6 +387,104 @@ class EventTest < ActiveSupport::TestCase end end + test 'sets missing timezone from coordinates' do + event = Event.create!(@mandatory.merge(user: users(:regular_user), title: 'Event in Evansville', + url: 'http://example.com', timezone: nil, + latitude: 37.97, longitude: -87.57, venue: 'Somewhere')) + assert_equal 'Central Time (US & Canada)', event.timezone + assert event.valid? + end + + test 'sets missing timezone from country with a single timezone' do + event = Event.create!(@mandatory.merge(user: users(:regular_user), title: 'Online event', url: 'http://example.com', + timezone: nil, presence: :online, country: 'United Kingdom')) + assert_equal 'London', event.timezone + end + + test 'marks default times of date-only events' do + event = Event.create!(@mandatory.merge(user: users(:regular_user), title: 'Date-only event', url: 'http://example.com', + timezone: nil, start: Date.new(2026, 11, 5), end: Date.new(2026, 11, 5))) + event.reload + assert_equal [9, 0, 0, 1], [event.start.hour, event.start.min, event.start.sec, event.start.usec] + assert_equal [17, 0, 0, 1], [event.end.hour, event.end.min, event.end.sec, event.end.usec] + assert event.default_times? + assert_equal '2026-11-05T09:00:00', event.local_iso8601(event.start), 'Marker should not be shown' + assert_includes event.to_ical, "DTSTART:20261105T090000\r\n" + end + + test 'does not infer timezone for date-only events' do + event = Event.create!(@mandatory.merge(user: users(:regular_user), title: 'Date-only event', url: 'http://example.com', + timezone: nil, start: Date.new(2026, 11, 5), end: Date.new(2026, 11, 5), + latitude: -37.81, longitude: 144.96, venue: 'Somewhere')) + assert_nil event.timezone, 'Default times should stay floating local times' + end + + test 'infers timezone for an event that really is 09:00-17:00' do + event = Event.create!(@mandatory.merge(user: users(:regular_user), title: 'Day event', url: 'http://example.com', + timezone: nil, start: Time.utc(2026, 11, 5, 9), end: Time.utc(2026, 11, 5, 17), + latitude: -37.81, longitude: 144.96, venue: 'Somewhere')) + refute event.default_times? + assert_equal 'Melbourne', event.timezone + end + + test 'clears inferred timezone when times become default, but not a chosen one' do + event = Event.create!(@mandatory.merge(user: users(:regular_user), title: 'Event', url: 'http://example.com', + timezone: nil, start: Time.utc(2026, 11, 5, 9), end: Time.utc(2026, 11, 5, 17), + latitude: -37.81, longitude: 144.96, venue: 'Somewhere')) + assert event.timezone_inferred? + + # e.g. ingested again from a source that only gives the date + event.update!(start: Date.new(2026, 11, 5), end: Date.new(2026, 11, 5)) + assert_nil event.timezone + refute event.timezone_inferred? + + event.update!(timezone: 'Melbourne') + event.update!(start: Date.new(2026, 11, 6), end: Date.new(2026, 11, 6)) + assert_equal 'Melbourne', event.timezone + end + + test 'marks inferred timezone until it is changed' do + event = Event.create!(@mandatory.merge(user: users(:regular_user), title: 'Event', url: 'http://example.com', + timezone: nil, country: 'United Kingdom', venue: 'Somewhere')) + assert_equal 'London', event.timezone + assert event.timezone_inferred? + + event.update!(title: 'Renamed event') + assert event.timezone_inferred?, 'Should still be inferred when the timezone is not changed' + + event.update!(timezone: 'Dublin') + assert_equal 'Dublin', event.timezone + refute event.timezone_inferred? + end + + test 'does not change a timezone that is set' do + event = Event.create!(@mandatory.merge(user: users(:regular_user), title: 'Event', url: 'http://example.com', + timezone: 'London', latitude: -37.81, longitude: 144.96, venue: 'Somewhere')) + assert_equal 'London', event.timezone + refute event.timezone_inferred? + end + + test 'no timezone for online event without location' do + event = Event.create!(@mandatory.merge(user: users(:regular_user), title: 'Online event', url: 'http://example.com', + timezone: nil, presence: :online)) + assert_nil event.timezone + end + + test 'ical export in UTC when the timezone is known' do + event = Event.new(title: 'Event', timezone: 'Perth', start: Time.utc(2022, 2, 11, 9, 45), + end: Time.utc(2022, 2, 11, 12, 50)) + ical = event.to_ical + assert_includes ical, 'DTSTART:20220211T014500Z' + assert_includes ical, 'DTEND:20220211T045000Z' + end + + test 'ical export in floating local time when the timezone is unknown' do + event = Event.new(title: 'Event', timezone: nil, start: Time.utc(2026, 11, 5, 9), end: Time.utc(2026, 11, 5, 17)) + ical = event.to_ical + assert_includes ical, "DTSTART:20261105T090000\r\n", 'Should be shown on the same day in any time zone' + assert_includes ical, "DTEND:20261105T170000\r\n" + end + test 'validates timezone if present' do event = Event.new(title: 'An event', url: 'https://myevent.com', timezone: 'UTC', user: users(:regular_user)) assert event.valid? diff --git a/test/unit/ingestors/carpentries_ingestor_test.rb b/test/unit/ingestors/carpentries_ingestor_test.rb index 045d721e2..26f63c57f 100644 --- a/test/unit/ingestors/carpentries_ingestor_test.rb +++ b/test/unit/ingestors/carpentries_ingestor_test.rb @@ -44,8 +44,8 @@ class CarpentriesIngestorTest < ActiveSupport::TestCase assert_equal 28.03107, event.longitude.to_f.round(5) assert_equal 'South Africa', event.country assert_equal 'The National Institute for Theoretical and Computational Sciences - NITheCS', event.venue - assert_equal Time.zone.parse('12 Aug 2024 09:00:00.000000000 UTC +00:00'), event.start - assert_equal Time.zone.parse('16 Aug 2024 17:00:00.000000000 UTC +00:00'), event.end + assert_equal Time.zone.parse('12 Aug 2024 09:00:00.000001000 UTC +00:00'), event.start + assert_equal Time.zone.parse('16 Aug 2024 17:00:00.000001000 UTC +00:00'), event.end assert_equal ['National Institute for Theoretical and Computational Sciences'], event.host_institutions assert event.online? end diff --git a/test/unit/ingestors/heptraining/gray_scott_ingestor_test.rb b/test/unit/ingestors/heptraining/gray_scott_ingestor_test.rb index 534d1fe91..4100b7d98 100644 --- a/test/unit/ingestors/heptraining/gray_scott_ingestor_test.rb +++ b/test/unit/ingestors/heptraining/gray_scott_ingestor_test.rb @@ -24,9 +24,12 @@ class GrayScottIngestorTest < ActiveSupport::TestCase assert_equal sample.url, 'https://cta-lapp.pages.in2p3.fr/cours/gray_scott_revolutions/grayscott2026/redirect.html?label=sec_gray_scott_webinar_memory_allocation_memory_profiling' assert_includes sample.description, 'Sometimes memory has become a major problem in applications' - assert_equal sample.end, '2026-02-26 10:30:00 +0000' - assert_equal sample.start, '2026-02-26 09:00:00 +0000' + # DTSTART;TZID=Europe/Paris:20260226T100000, DTEND;TZID=Europe/Paris:20260226T113000 assert_equal sample.timezone, 'Paris' + assert_equal '2026-02-26T10:00:00+01:00', sample.local_iso8601(sample.start) + assert_equal '2026-02-26T11:30:00+01:00', sample.local_iso8601(sample.end) + assert_equal Time.utc(2026, 2, 26, 9, 0), sample.start_utc + assert_equal Time.utc(2026, 2, 26, 10, 30), sample.end_utc assert_includes sample.venue, 'teratec.webex.com' assert_equal sample.organizer, 'Someone, SomeoneElse' end diff --git a/test/unit/ingestors/ical_ingestor_test.rb b/test/unit/ingestors/ical_ingestor_test.rb index c4cd7754f..dfc88fe01 100644 --- a/test/unit/ingestors/ical_ingestor_test.rb +++ b/test/unit/ingestors/ical_ingestor_test.rb @@ -91,8 +91,10 @@ class IcalIngestorTest < ActiveSupport::TestCase assert_includes event.description, 'The Pawsey Supercomputing Research Centre takes prides in its Summer Internship Program' assert_includes event.description, 'range of trainings we immerse students in during Week 1 of the Program (and throughout).' assert_equal 'Perth', event.timezone.to_s, "event title[#{event.title}] timezone not matched" - assert_equal '2022-02-11 01:45:00 UTC', event.start.utc.to_s, "event title[#{event.title}] start not matched" - assert_equal '2022-02-11 04:50:00 UTC', event.end.utc.to_s, "event title[#{event.title}] end not matched" + # DTSTART;TZID=Australia/Perth:20220211T094500, DTEND;TZID=Australia/Perth:20220211T125000 + assert_equal '2022-02-11 01:45:00 UTC', event.start_utc.to_s, "event title[#{event.title}] start not matched" + assert_equal '2022-02-11 04:50:00 UTC', event.end_utc.to_s, "event title[#{event.title}] end not matched" + assert_equal '2022-02-11T09:45:00+08:00', event.local_iso8601(event.start), "event title[#{event.title}] local start not matched" title = 'P\'Con - Experience with porting and scaling codes on AMD GPUs' event = check_event_exists title, 'https://pawsey.org.au/event/experience-with-porting-and-scaling-codes-on-amd-gpus/' @@ -111,7 +113,7 @@ class IcalIngestorTest < ActiveSupport::TestCase # TODO: check updated title = 'PaCER Seminar: Computational Fluid Dynamics' event = check_event_exists title, 'https://pawsey.org.au/event/pacer-seminar-computational-fluid-dynamics/' - assert_equal '2022-06-15 03:00:00 UTC', event.end.utc.to_s, "event title[#{event.title}] updated end not matched" + assert_equal '2022-06-15 03:00:00 UTC', event.end_utc.to_s, "event title[#{event.title}] updated end not matched" assert event.description != 'MyText', "event title[#{event.title}] description not updated" assert event.description.size > 100, "event title[#{event.title}] description too short" assert event.online?, "event title[#{event.title}] online not matched" diff --git a/test/unit/ingestors/ingestor_test.rb b/test/unit/ingestors/ingestor_test.rb index 36e7f58c0..c5708bc7a 100644 --- a/test/unit/ingestors/ingestor_test.rb +++ b/test/unit/ingestors/ingestor_test.rb @@ -591,4 +591,40 @@ def run_filter(source_filter) end end + test 'gives the local clock time of icalendar dates and times, whatever the server time zone' do + ingestor = Ingestors::Ingestor.new + calendar = <<~ICS + BEGIN:VCALENDAR + BEGIN:VEVENT + SUMMARY:zoned + DTSTART;TZID=Australia/Perth:20220211T094500 + END:VEVENT + BEGIN:VEVENT + SUMMARY:utc + DTSTART:20220211T094500Z + END:VEVENT + BEGIN:VEVENT + SUMMARY:floating + DTSTART:20220211T094500 + END:VEVENT + BEGIN:VEVENT + SUMMARY:all-day + DTSTART;VALUE=DATE:20220211 + END:VEVENT + END:VCALENDAR + ICS + + # The clock time as written in the calendar; the time zone is kept separately, in the event's timezone + %w[UTC Europe/Oslo Asia/Tokyo America/New_York].each do |tz| + mock_timezone(tz) + clock = Icalendar::Event.parse(calendar).to_h do |event| + [event.summary.to_s, ingestor.ical_local_time(event.dtstart).strftime('%Y-%m-%d %H:%M')] + end + assert_equal({ 'zoned' => '2022-02-11 09:45', 'utc' => '2022-02-11 09:45', 'floating' => '2022-02-11 09:45', + 'all-day' => '2022-02-11 00:00' }, clock, "Server time zone #{tz}") + ensure + reset_timezone + end + assert_nil ingestor.ical_local_time(nil) + end end diff --git a/test/unit/ingestors/taxila/citizen_science_ingestor_test.rb b/test/unit/ingestors/taxila/citizen_science_ingestor_test.rb index a5b211dcd..57deb55a7 100644 --- a/test/unit/ingestors/taxila/citizen_science_ingestor_test.rb +++ b/test/unit/ingestors/taxila/citizen_science_ingestor_test.rb @@ -46,8 +46,8 @@ class CitizenScienceIngestorTest < ActiveSupport::TestCase # check other fields assert_equal 'CitizenScience', event.source assert_equal 'Amsterdam', event.timezone - assert_equal Time.zone.parse('Mon, 19 Feb 2026 09:00:00.000000000 UTC +00:00'), event.start - assert_equal Time.zone.parse('Mon, 19 Feb 2026 10:00:00.000000000 UTC +00:00'), event.end + assert_equal Time.zone.parse('Mon, 19 Feb 2026 09:00:00.000001000 UTC +00:00'), event.start + assert_equal Time.zone.parse('Mon, 19 Feb 2026 10:00:00.000001000 UTC +00:00'), event.end assert_equal 'Online', event.venue assert event.online? end diff --git a/test/unit/ingestors/taxila/dans_ingestor_test.rb b/test/unit/ingestors/taxila/dans_ingestor_test.rb index 0d0798efa..51fa8fa0a 100644 --- a/test/unit/ingestors/taxila/dans_ingestor_test.rb +++ b/test/unit/ingestors/taxila/dans_ingestor_test.rb @@ -52,7 +52,7 @@ class DansIngestorTest < ActiveSupport::TestCase assert_equal 'DANS', event.source assert_equal 'Amsterdam', event.timezone assert_equal ['FAIR and Open data'], event.keywords - assert_equal Time.zone.parse('Mon, 24 Oct 2025 09:00:00.000000000 UTC +00:00'), event.start - assert_equal Time.zone.parse('Mon, 24 Oct 2025 17:00:00.000000000 UTC +00:00'), event.end + assert_equal Time.zone.parse('Mon, 24 Oct 2025 09:00:00.000001000 UTC +00:00'), event.start + assert_equal Time.zone.parse('Mon, 24 Oct 2025 17:00:00.000001000 UTC +00:00'), event.end end end diff --git a/test/unit/ingestors/taxila/dcc_ingestor_test.rb b/test/unit/ingestors/taxila/dcc_ingestor_test.rb index 736daf0bb..306f98658 100644 --- a/test/unit/ingestors/taxila/dcc_ingestor_test.rb +++ b/test/unit/ingestors/taxila/dcc_ingestor_test.rb @@ -51,8 +51,8 @@ class DccIngestorTest < ActiveSupport::TestCase # check other fields assert_equal 'DCC', event.source assert_equal 'Amsterdam', event.timezone - assert_equal Time.zone.parse('Fri, 18 Sep 2025 09:00:00.000000000 UTC +00:00'), event.start - assert_equal Time.zone.parse('Sat, 10 Oct 2025 17:00:00.000000000 UTC +00:00'), event.end + assert_equal Time.zone.parse('Fri, 18 Sep 2025 09:00:00.000001000 UTC +00:00'), event.start + assert_equal Time.zone.parse('Sat, 10 Oct 2025 17:00:00.000001000 UTC +00:00'), event.end assert_equal 'Utrecht, SURF en HU', event.venue end end diff --git a/test/unit/ingestors/taxila/dtls_ingestor_test.rb b/test/unit/ingestors/taxila/dtls_ingestor_test.rb index 1810c31f1..2c3b6107b 100644 --- a/test/unit/ingestors/taxila/dtls_ingestor_test.rb +++ b/test/unit/ingestors/taxila/dtls_ingestor_test.rb @@ -53,7 +53,7 @@ class DtlsIngestorTest < ActiveSupport::TestCase assert_equal 'Amsterdam', event.timezone assert_equal 'Wageningen Campus', event.city assert_equal 'Netherlands', event.country - assert_equal Time.zone.parse('Mon, 13 Feb 2023 09:00:00.000000000 UTC +00:00'), event.start - assert_equal Time.zone.parse('Fri, 17 Feb 2023 17:00:00.000000000 UTC +00:00'), event.end + assert_equal Time.zone.parse('Mon, 13 Feb 2023 09:00:00.000001000 UTC +00:00'), event.start + assert_equal Time.zone.parse('Fri, 17 Feb 2023 17:00:00.000001000 UTC +00:00'), event.end end end diff --git a/test/unit/ingestors/taxila/han_ingestor_test.rb b/test/unit/ingestors/taxila/han_ingestor_test.rb index 2f9624762..3413b7079 100644 --- a/test/unit/ingestors/taxila/han_ingestor_test.rb +++ b/test/unit/ingestors/taxila/han_ingestor_test.rb @@ -45,7 +45,7 @@ class HanIngestorTest < ActiveSupport::TestCase assert_equal 'HAN', event.source assert_equal 'Amsterdam', event.timezone assert_equal 'Nijmegen, Laan van Scheut 2', event.venue - assert_equal Time.zone.parse('Tue, 1 Sep 2026 09:00:00.000000000 UTC +00:00'), event.start - assert_equal Time.zone.parse('Tue, 1 Sep 2026 17:00:00.000000000 UTC +00:00'), event.end + assert_equal Time.zone.parse('Tue, 1 Sep 2026 09:00:00.000001000 UTC +00:00'), event.start + assert_equal Time.zone.parse('Tue, 1 Sep 2026 17:00:00.000001000 UTC +00:00'), event.end end end diff --git a/test/unit/ingestors/taxila/nwo_ingestor_test.rb b/test/unit/ingestors/taxila/nwo_ingestor_test.rb index 348f9596e..3cfa20def 100644 --- a/test/unit/ingestors/taxila/nwo_ingestor_test.rb +++ b/test/unit/ingestors/taxila/nwo_ingestor_test.rb @@ -52,7 +52,7 @@ class NwoIngestorTest < ActiveSupport::TestCase assert_equal 'NL Polar day 2025', event.title assert_equal 'Amsterdam', event.timezone assert_equal 'NWO', event.source - assert_equal Time.zone.parse('Mon, 8 Apr 2025 09:00:00.000000000 UTC +00:00'), event.start - assert_equal Time.zone.parse('Mon, 8 Apr 2025 17:00:00.000000000 UTC +00:00'), event.end + assert_equal Time.zone.parse('Mon, 8 Apr 2025 09:00:00.000001000 UTC +00:00'), event.start + assert_equal Time.zone.parse('Mon, 8 Apr 2025 17:00:00.000001000 UTC +00:00'), event.end end end diff --git a/test/unit/ingestors/taxila/oscd_ingestor_test.rb b/test/unit/ingestors/taxila/oscd_ingestor_test.rb index 3bc34a182..ba5a282ff 100644 --- a/test/unit/ingestors/taxila/oscd_ingestor_test.rb +++ b/test/unit/ingestors/taxila/oscd_ingestor_test.rb @@ -52,8 +52,8 @@ class OscdIngestorTest < ActiveSupport::TestCase # check other fields assert_equal 'OSCD', event.source assert_equal 'Amsterdam', event.timezone - assert_equal Time.zone.parse('Tue, 21 Jan 2019 09:00:00.000000000 UTC +00:00'), event.start - assert_equal Time.zone.parse('Tue, 21 Jan 2019 17:00:00.000000000 UTC +00:00'), event.end + assert_equal Time.zone.parse('Tue, 21 Jan 2019 09:00:00.000001000 UTC +00:00'), event.start + assert_equal Time.zone.parse('Tue, 21 Jan 2019 17:00:00.000001000 UTC +00:00'), event.end assert_equal 'Online - Register here', event.venue assert event.online? end diff --git a/test/unit/ingestors/taxila/rdnl_ingestor_test.rb b/test/unit/ingestors/taxila/rdnl_ingestor_test.rb index c1c43b11c..a0297078e 100644 --- a/test/unit/ingestors/taxila/rdnl_ingestor_test.rb +++ b/test/unit/ingestors/taxila/rdnl_ingestor_test.rb @@ -52,8 +52,8 @@ class RDNLIngestorTest < ActiveSupport::TestCase assert_equal 'RDNL', event.source assert_equal 'Amsterdam', event.timezone assert_equal 'SURF, Health-RI', event.venue - assert_equal Time.zone.parse('Mon, 16 Apr 2026 09:00:00.000000000 UTC +00:00'), event.start - assert_equal Time.zone.parse('Fri, 4 Jun 2026 17:00:00.000000000 UTC +00:00'), event.end + assert_equal Time.zone.parse('Mon, 16 Apr 2026 09:00:00.000001000 UTC +00:00'), event.start + assert_equal Time.zone.parse('Fri, 4 Jun 2026 17:00:00.000001000 UTC +00:00'), event.end assert_equal 'charge', event&.cost_basis end end diff --git a/test/unit/ingestors/taxila/rug_ingestor_test.rb b/test/unit/ingestors/taxila/rug_ingestor_test.rb index b2e3a6d12..d86a68713 100644 --- a/test/unit/ingestors/taxila/rug_ingestor_test.rb +++ b/test/unit/ingestors/taxila/rug_ingestor_test.rb @@ -65,8 +65,8 @@ class RugIngestorTest < ActiveSupport::TestCase # check other fields assert_equal 'RUG', event.source assert_equal 'Amsterdam', event.timezone - assert_equal Time.zone.parse('Sat, 15 Feb 2025 09:00:00.000000000 UTC +00:00'), event.start - assert_equal Time.zone.parse('Sun, 04 jan 2026 17:00:00.000000000 UTC +00:00'), event.end + assert_equal Time.zone.parse('Sat, 15 Feb 2025 09:00:00.000001000 UTC +00:00'), event.start + assert_equal Time.zone.parse('Sun, 04 jan 2026 17:00:00.000001000 UTC +00:00'), event.end assert_equal 'University museum, Oude Kijk in Het Jatstraat 7A, 9712 EA Groningen', event.venue end end diff --git a/test/unit/ingestors/taxila/tdcc_ingestor_test.rb b/test/unit/ingestors/taxila/tdcc_ingestor_test.rb index 3f2498086..124d99cee 100644 --- a/test/unit/ingestors/taxila/tdcc_ingestor_test.rb +++ b/test/unit/ingestors/taxila/tdcc_ingestor_test.rb @@ -51,8 +51,8 @@ class TdccIngestorTest < ActiveSupport::TestCase # check other fields assert_equal 'TDCC', event.source assert_equal 'Amsterdam', event.timezone - assert_equal Time.zone.parse('Mon, 9 Oct 2025 09:00:00.000000000 UTC +00:00'), event.start - assert_equal Time.zone.parse('Mon, 9 Oct 2025 17:00:00.000000000 UTC +00:00'), event.end + assert_equal Time.zone.parse('Mon, 9 Oct 2025 09:00:00.000001000 UTC +00:00'), event.start + assert_equal Time.zone.parse('Mon, 9 Oct 2025 17:00:00.000001000 UTC +00:00'), event.end assert_equal 'Social Impact Factory', event.venue end end diff --git a/test/unit/ingestors/taxila/uhasselt_ingestor_test.rb b/test/unit/ingestors/taxila/uhasselt_ingestor_test.rb index 9903843c1..4fb450a3f 100644 --- a/test/unit/ingestors/taxila/uhasselt_ingestor_test.rb +++ b/test/unit/ingestors/taxila/uhasselt_ingestor_test.rb @@ -47,8 +47,8 @@ class UhasseltIngestorTest < ActiveSupport::TestCase # check other fields assert_equal 'UHasselt', event.source assert_equal 'Amsterdam', event.timezone - assert_equal Time.zone.parse('Mon, 16 Mar 2026 09:00:00.000000000 UTC +00:00'), event.start - assert_equal Time.zone.parse('Mon, 16 Mar 2026 17:00:00.000000000 UTC +00:00'), event.end + assert_equal Time.zone.parse('Mon, 16 Mar 2026 09:00:00.000001000 UTC +00:00'), event.start + assert_equal Time.zone.parse('Mon, 16 Mar 2026 17:00:00.000001000 UTC +00:00'), event.end assert_equal 'campus Diepenbeek', event.venue refute event.online? end diff --git a/test/unit/ingestors/taxila/wur_ingestor_test.rb b/test/unit/ingestors/taxila/wur_ingestor_test.rb index c50295b06..5434def05 100644 --- a/test/unit/ingestors/taxila/wur_ingestor_test.rb +++ b/test/unit/ingestors/taxila/wur_ingestor_test.rb @@ -52,7 +52,7 @@ class WurIngestorTest < ActiveSupport::TestCase assert_equal 'WUR', event.source assert_equal 'Amsterdam', event.timezone assert_equal 'Omnia - Building 105', event.venue - assert_equal Time.zone.parse('2026-08-24 09:00:00'), event.start - assert_equal Time.zone.parse('2026-08-24 17:00:00'), event.end + assert_equal Time.zone.parse('2026-08-24 09:00:00.000001'), event.start + assert_equal Time.zone.parse('2026-08-24 17:00:00.000001'), event.end end end diff --git a/test/unit/timezone_lookup_test.rb b/test/unit/timezone_lookup_test.rb new file mode 100644 index 000000000..1f3d30653 --- /dev/null +++ b/test/unit/timezone_lookup_test.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'test_helper' + +class TimezoneLookupTest < ActiveSupport::TestCase + test 'finds time zone from coordinates' do + assert_equal 'Melbourne', TimezoneLookup.find(latitude: -37.81, longitude: 144.96) + assert_equal 'Perth', TimezoneLookup.find(latitude: -31.95, longitude: 115.86) + assert_equal 'Mountain Time (US & Canada)', TimezoneLookup.find(latitude: 43.6, longitude: -116.2) # Boise + end + + test 'finds time zone from coordinates near a time zone border' do + # Evansville, Indiana is on Central time, although the nearest time zone city (Petersburg, Indiana) is Eastern + assert_equal 'Central Time (US & Canada)', TimezoneLookup.find(latitude: 37.97, longitude: -87.57) + end + + test 'prefers the time zone name of the city' do + assert_equal 'London', TimezoneLookup.find(latitude: 51.5, longitude: -0.12) + assert_equal 'London', TimezoneLookup.find(latitude: 55.95, longitude: -3.19) # Edinburgh is also Europe/London + end + + test 'gives a time zone with the same offsets when TeSS has no name for it' do + # Atlantic/Canary is not one of the Rails time zones, but has the same offsets as Lisbon + assert_equal 'Lisbon', TimezoneLookup.find(latitude: 28.12, longitude: -15.43) + end + + test 'coordinates take precedence over country' do + assert_equal 'Moscow', TimezoneLookup.find(latitude: 45, longitude: 45, country: 'United Kingdom') + end + + test 'finds time zone from country with a single time zone' do + assert_equal 'London', TimezoneLookup.find(country: 'United Kingdom') + assert_equal 'London', TimezoneLookup.find(country: 'GB') + assert_equal 'Berlin', TimezoneLookup.find(country: 'Germany') # also has Europe/Busingen, with the same offsets + end + + test 'no time zone for country with several time zones' do + assert_nil TimezoneLookup.find(country: 'United States') + assert_nil TimezoneLookup.find(country: 'Spain') # Canary Islands are an hour behind + end + + test 'no time zone without location' do + assert_nil TimezoneLookup.find + assert_nil TimezoneLookup.find(country: '') + assert_nil TimezoneLookup.find(country: 'BigCountry') + assert_nil TimezoneLookup.find(latitude: 0, longitude: -30) # Atlantic ocean + end + + test 'no time zone when the lookups fail' do + WhereTZ.stub(:lookup, ->(*) { raise ArgumentError, 'Point outside any known timezone' }) do + assert_nil TimezoneLookup.zone_at(51.5, -0.12) + end + TZInfo::Country.stub(:get, ->(*) { raise TZInfo::InvalidCountryCode }) do + assert_nil TimezoneLookup.country_zone('United Kingdom') + end + assert_nil TimezoneLookup.rails_name('Not/A_Zone') + refute TimezoneLookup.same_offsets?('London', 'Not a zone') + end +end diff --git a/test/workers/geocoding_worker_test.rb b/test/workers/geocoding_worker_test.rb index c11948254..6cf86539b 100644 --- a/test/workers/geocoding_worker_test.rb +++ b/test/workers/geocoding_worker_test.rb @@ -34,6 +34,35 @@ class GeocodingWorkerTest < ActiveSupport::TestCase assert_equal 45, event.longitude end + test 'set missing timezone from geocoded coordinates' do + mock_nominatim + + event = events(:kilburn) + event.update_columns(timezone: nil, start: Time.utc(2019, 11, 23, 10), end: Time.utc(2019, 11, 23, 16)) + + Sidekiq::Testing.inline! do + GeocodingWorker.perform_async([event.id, event.address]) + end + + assert_equal 'London', event.reload.timezone + end + + test 'do not set timezone from geocoded coordinates for date-only event' do + mock_nominatim + + event = events(:kilburn) # dates only, so given the default times when saved + event.update_column(:timezone, nil) + + Sidekiq::Testing.inline! do + GeocodingWorker.perform_async([event.id, event.address]) + end + + event.reload + assert event.latitude.present? + assert event.default_times? + assert_nil event.timezone + end + test 'gracefully handle geocoding for event that no longer exists' do Sidekiq::Testing.inline! do assert_nothing_raised do