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') } %>
+
- <%= 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