From c2a18c102e8acb4ac782700e938b46439b7f2f9b Mon Sep 17 00:00:00 2001
From: Stian Soiland-Reyes
Date: Thu, 22 Oct 2026 23:00:13 +0000
Subject: [PATCH 01/10] Give Bioschemas event dates in ISO 8601 with the
event's UTC offset
startDate and endDate were serialised by JSON.pretty_generate with
Time#to_s ("2015-11-23 10:16:33 UTC") and presented the event's local
(wall clock) time, which TeSS stores in a UTC column, as UTC. Give them
in ISO 8601 with the offset of the event's time zone on that date, e.g.
"2015-11-23T10:16:33-05:00", or without an offset (local time) when the
time zone is unknown.
Co-Authored-By: Claude Opus 5.5
---
lib/bioschemas/course_instance_generator.rb | 4 ++--
lib/bioschemas/event_generator.rb | 4 ++--
lib/bioschemas/generator.rb | 11 +++++++++
test/integration/bioschemas_test.rb | 26 +++++++++++++++++----
4 files changed, 37 insertions(+), 8 deletions(-)
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..b1ac9dc42 100644
--- a/lib/bioschemas/generator.rb
+++ b/lib/bioschemas/generator.rb
@@ -79,6 +79,17 @@ def self.term(term)
}
end
+ # An event's start or end time as an ISO 8601 date-time with the UTC offset of the event's time zone, e.g.
+ # "2015-11-23T10:16:33-05:00". TeSS stores the 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 self.local_time(event, time)
+ return nil if time.blank?
+
+ wall_clock = time.strftime('%Y-%m-%dT%H:%M:%S')
+ zone = ActiveSupport::TimeZone[event.timezone] if event.timezone.present?
+ zone ? zone.parse(wall_clock).iso8601 : wall_clock
+ end
+
def self.person(person)
p = {
"@type" => "Person",
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)
From 63e276ed1efeb5cdceab6eb75a76a696447cc52f Mon Sep 17 00:00:00 2001
From: Stian Soiland-Reyes
Date: Thu, 22 Oct 2026 23:00:13 +0000
Subject: [PATCH 02/10] Infer missing event time zones from the location
Most events have no time zone, so their times cannot be placed. When an
event without one is saved, look it up offline from its coordinates
(timezone-boundary-builder polygons, via the wheretz gem) or, failing
that, from a country with a single time zone, as one of the time zone
names TeSS accepts. Online events often have neither and keep none.
The time zone is marked as inferred (new timezone_inferred column),
shown as "(inferred)" on the event page and in the JSON API, until it
is changed, e.g. by the user. The event form suggests the time zone of
the location, and warns if the selected one differs, without
preventing the user from keeping their own choice.
rake tess:infer_event_timezones sets the time zone of existing events.
Co-Authored-By: Claude Opus 5.5
---
Gemfile | 1 +
Gemfile.lock | 2 +
app/assets/javascripts/events.js | 51 +++++++++
app/assets/javascripts/map_search.js | 3 +-
app/controllers/events_controller.rb | 18 +++
app/models/event.rb | 10 ++
app/serializers/event_serializer.rb | 2 +-
app/views/events/_form.html.erb | 5 +
app/views/events/show.html.erb | 2 +-
config/locales/en.yml | 7 ++
config/routes.rb | 1 +
...2230000_add_timezone_inferred_to_events.rb | 5 +
db/schema.rb | 3 +-
docs/docker.md | 5 +
lib/tasks/tess.rake | 17 +++
lib/timezone_lookup.rb | 103 ++++++++++++++++++
public/api/definitions/tess.yml | 4 +
test/controllers/events_controller_test.rb | 58 ++++++++++
test/models/event_test.rb | 41 +++++++
test/unit/timezone_lookup_test.rb | 46 ++++++++
test/workers/geocoding_worker_test.rb | 13 +++
21 files changed, 393 insertions(+), 4 deletions(-)
create mode 100644 db/migrate/20261022230000_add_timezone_inferred_to_events.rb
create mode 100644 lib/timezone_lookup.rb
create mode 100644 test/unit/timezone_lookup_test.rb
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/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..aef182f8f 100644
--- a/app/controllers/events_controller.rb
+++ b/app/controllers/events_controller.rb
@@ -98,6 +98,24 @@ def new
end: DateTime.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])
+ selected = params[:timezone].presence
+ message = if suggestion.nil? || (selected && TimezoneLookup.same_offsets?(selected, suggestion))
+ nil
+ elsif selected
+ t('events.timezone_suggestion.mismatch', suggestion:, selected:)
+ else
+ t('events.timezone_suggestion.blank', suggestion:)
+ end
+
+ render json: { timezone: suggestion, message:, button: (t('events.timezone_suggestion.use', suggestion:) if message) }
+ end
+
# GET /events/1/clone
def clone
authorize @event
diff --git a/app/models/event.rb b/app/models/event.rb
index 030b25f10..727477aa5 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -30,6 +30,8 @@ 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?
+ before_save :timezone_from_location, if: -> { timezone.blank? }
after_save :enqueue_geocoding_worker, if: :address_changed?
if TeSS::Config.solr_enabled
@@ -385,6 +387,14 @@ 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.
+ def timezone_from_location
+ self.timezone = TimezoneLookup.find(latitude:, longitude:, country:)
+ self.timezone_inferred = timezone.present?
+ 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..4d89e5b9a 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,
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..33b10a84a
--- /dev/null
+++ b/db/migrate/20261022230000_add_timezone_inferred_to_events.rb
@@ -0,0 +1,5 @@
+class AddTimezoneInferredToEvents < ActiveRecord::Migration[7.2]
+ 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/lib/tasks/tess.rake b/lib/tasks/tess.rake
index 665865ac3..ff634af11 100644
--- a/lib/tasks/tess.rake
+++ b/lib/tasks/tess.rake
@@ -206,6 +206,23 @@ 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|
+ 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 '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..43d7f8544
--- /dev/null
+++ b/lib/timezone_lookup.rb
@@ -0,0 +1,103 @@
+# 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)
+ return name if name
+
+ offsets = offsets(canonical)
+ candidates = mapping.select { |_, id| offsets(id) == offsets }.keys
+ latitude, longitude = locations[canonical] if latitude.nil? || longitude.nil?
+ 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
+ rescue TZInfo::InvalidTimezoneIdentifier
+ nil
+ 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..44e79ed1e 100644
--- a/public/api/definitions/tess.yml
+++ b/public/api/definitions/tess.yml
@@ -1091,6 +1091,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..1c95e9d62 100644
--- a/test/controllers/events_controller_test.rb
+++ b/test/controllers/events_controller_test.rb
@@ -1719,4 +1719,62 @@ 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
end
diff --git a/test/models/event_test.rb b/test/models/event_test.rb
index 55ae09150..92af6a4fe 100644
--- a/test/models/event_test.rb
+++ b/test/models/event_test.rb
@@ -387,6 +387,47 @@ 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 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 '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/timezone_lookup_test.rb b/test/unit/timezone_lookup_test.rb
new file mode 100644
index 000000000..1141b7d71
--- /dev/null
+++ b/test/unit/timezone_lookup_test.rb
@@ -0,0 +1,46 @@
+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
+end
diff --git a/test/workers/geocoding_worker_test.rb b/test/workers/geocoding_worker_test.rb
index c11948254..85eb418ee 100644
--- a/test/workers/geocoding_worker_test.rb
+++ b/test/workers/geocoding_worker_test.rb
@@ -34,6 +34,19 @@ 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_column(:timezone, nil)
+
+ Sidekiq::Testing.inline! do
+ GeocodingWorker.perform_async([event.id, event.address])
+ end
+
+ assert_equal 'London', event.reload.timezone
+ end
+
test 'gracefully handle geocoding for event that no longer exists' do
Sidekiq::Testing.inline! do
assert_nothing_raised do
From 74a4d8e8d93970d20af7846ba9b7c22c74a6abcd Mon Sep 17 00:00:00 2001
From: Stian Soiland-Reyes
Date: Thu, 22 Oct 2026 23:14:35 +0000
Subject: [PATCH 03/10] Give event times with the timezone's offset in the JSON
APIs too
The JSON and JSON-API representations gave the stored local (wall
clock) time as UTC, e.g. "2026-10-22T09:00:00.000Z" for an event at
09:00 in Buenos Aires. Give them like the Bioschemas dates, with the
offset of the event's time zone ("2026-10-22T09:00:00-03:00"), or
without an offset when the time zone is unknown, using one
Event#local_iso8601 for all three. The JSON event list now includes
timezone and timezone_inferred, like a single event.
Clients that parse the times with their offset now get the right
instant, including TeSS's own TeSS ingestor, which reads JSON-API and
sets the time zone to UTC.
Co-Authored-By: Claude Opus 5.5
---
app/models/event.rb | 11 +++++++
app/serializers/event_serializer.rb | 9 ++++++
app/views/events/index.json.jbuilder | 6 +++-
app/views/events/show.json.jbuilder | 5 ++-
lib/bioschemas/generator.rb | 10 ++----
public/api/definitions/tess.yml | 6 ++--
test/controllers/events_controller_test.rb | 36 ++++++++++++++++++++++
7 files changed, 71 insertions(+), 12 deletions(-)
diff --git a/app/models/event.rb b/app/models/event.rb
index 727477aa5..35e243d94 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -196,6 +196,17 @@ 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 = ActiveSupport::TimeZone[timezone] if timezone.present?
+ zone ? zone.parse(wall_clock).iso8601 : wall_clock
+ end
+
def end_local
set_to_local self.end
end
diff --git a/app/serializers/event_serializer.rb b/app/serializers/event_serializer.rb
index 4d89e5b9a..ce1b8bb7a 100644
--- a/app/serializers/event_serializer.rb
+++ b/app/serializers/event_serializer.rb
@@ -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/index.json.jbuilder b/app/views/events/index.json.jbuilder
index cff3c0413..6ea7c71aa 100644
--- a/app/views/events/index.json.jbuilder
+++ b/app/views/events/index.json.jbuilder
@@ -1,4 +1,5 @@
-base_fields = [:id, :external_id, :title, :subtitle, :url, :organizer, :description, :start, :end, :sponsors, :venue,
+base_fields = [:id, :external_id, :title, :subtitle, :url, :organizer, :description, :start, :end, :timezone, :timezone_inferred, :sponsors,
+ :venue,
:city, :country, :postcode, :latitude, :longitude, :created_at, :updated_at, :source, :slug,
:content_provider_id, :user_id, :last_scraped, :scraper_record, :keywords, :event_types,
:target_audience, :capacity, :eligibility, :contact, :host_institutions, :prerequisites,
@@ -9,6 +10,9 @@ json.array!(@events) do |event|
fields += Event::SENSITIVE_FIELDS if policy(event).view_report?
json.extract! event, *fields
+ # Local time with the offset of the event's time zone, not the stored wall clock time as UTC
+ json.set! :start, event.local_iso8601(event.start)
+ json.set! :end, event.local_iso8601(event.end)
json.online(event.online? || event.hybrid?)
json.partial! 'common/ontology_terms', type: 'scientific_topics', resource: event
diff --git a/app/views/events/show.json.jbuilder b/app/views/events/show.json.jbuilder
index 1a20202bf..38285bc38 100644
--- a/app/views/events/show.json.jbuilder
+++ b/app/views/events/show.json.jbuilder
@@ -5,7 +5,7 @@ fields = [
:keywords, :event_types, :fields,
- :start, :end, :duration, :timezone, :language,
+ :start, :end, :duration, :timezone, :timezone_inferred, :language,
:organizer, :sponsors, :contact, :host_institutions,
@@ -22,6 +22,9 @@ fields = [
fields += Event::SENSITIVE_FIELDS if policy(@event).view_report?
json.extract! @event, *fields
+# Local time with the offset of the event's time zone, not the stored wall clock time as UTC
+json.set! :start, @event.local_iso8601(@event.start)
+json.set! :end, @event.local_iso8601(@event.end)
json.online(@event.online? || @event.hybrid?)
json.partial! 'common/ontology_terms', type: 'scientific_topics', resource: @event
diff --git a/lib/bioschemas/generator.rb b/lib/bioschemas/generator.rb
index b1ac9dc42..a5d743743 100644
--- a/lib/bioschemas/generator.rb
+++ b/lib/bioschemas/generator.rb
@@ -79,15 +79,9 @@ def self.term(term)
}
end
- # An event's start or end time as an ISO 8601 date-time with the UTC offset of the event's time zone, e.g.
- # "2015-11-23T10:16:33-05:00". TeSS stores the 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.
+ # 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)
- return nil if time.blank?
-
- wall_clock = time.strftime('%Y-%m-%dT%H:%M:%S')
- zone = ActiveSupport::TimeZone[event.timezone] if event.timezone.present?
- zone ? zone.parse(wall_clock).iso8601 : wall_clock
+ event.local_iso8601(time)
end
def self.person(person)
diff --git a/public/api/definitions/tess.yml b/public/api/definitions/tess.yml
index 44e79ed1e..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
diff --git a/test/controllers/events_controller_test.rb b/test/controllers/events_controller_test.rb
index 1c95e9d62..cfdc73c24 100644
--- a/test/controllers/events_controller_test.rb
+++ b/test/controllers/events_controller_test.rb
@@ -1777,4 +1777,40 @@ class EventsControllerTest < ActionController::TestCase
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 and 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 }
+ json = JSON.parse(response.body)
+ assert_equal '2026-10-22T09:00:00-03:00', json['start']
+ assert_equal '2026-10-22T17:00:00-03:00', json['end']
+
+ 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 and 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 }
+ assert_equal '2026-10-22T09:00:00', JSON.parse(response.body)['start']
+
+ 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 include timezone in json event list' do
+ @event.update_columns(timezone: 'Buenos Aires', timezone_inferred: true, start: 1.day.from_now.change(hour: 9),
+ end: 1.day.from_now.change(hour: 17))
+
+ get :index, params: { format: :json }
+ event = JSON.parse(response.body).detect { |e| e['id'] == @event.id }
+ assert_equal 'Buenos Aires', event['timezone']
+ assert event['timezone_inferred']
+ assert_match(/T09:00:00-03:00\z/, event['start'])
+ assert_valid_legacy_json_response
+ end
end
From cadfab35bdd8ec5efee848a8531b96669a7b094f Mon Sep 17 00:00:00 2001
From: Stian Soiland-Reyes
Date: Thu, 22 Oct 2026 23:16:07 +0000
Subject: [PATCH 04/10] Leave the legacy JSON API unchanged
Keep the legacy JSON representation (events.json, events/1.json) as it
was, without new keys and with the stored times as before; whether its
timestamps should also carry the event's offset is left as a separate
change (branch event-timezones-legacy-json).
Co-Authored-By: Claude Opus 5.5
---
app/views/events/index.json.jbuilder | 6 +----
app/views/events/show.json.jbuilder | 5 +---
test/controllers/events_controller_test.rb | 27 +++++++---------------
3 files changed, 10 insertions(+), 28 deletions(-)
diff --git a/app/views/events/index.json.jbuilder b/app/views/events/index.json.jbuilder
index 6ea7c71aa..cff3c0413 100644
--- a/app/views/events/index.json.jbuilder
+++ b/app/views/events/index.json.jbuilder
@@ -1,5 +1,4 @@
-base_fields = [:id, :external_id, :title, :subtitle, :url, :organizer, :description, :start, :end, :timezone, :timezone_inferred, :sponsors,
- :venue,
+base_fields = [:id, :external_id, :title, :subtitle, :url, :organizer, :description, :start, :end, :sponsors, :venue,
:city, :country, :postcode, :latitude, :longitude, :created_at, :updated_at, :source, :slug,
:content_provider_id, :user_id, :last_scraped, :scraper_record, :keywords, :event_types,
:target_audience, :capacity, :eligibility, :contact, :host_institutions, :prerequisites,
@@ -10,9 +9,6 @@ json.array!(@events) do |event|
fields += Event::SENSITIVE_FIELDS if policy(event).view_report?
json.extract! event, *fields
- # Local time with the offset of the event's time zone, not the stored wall clock time as UTC
- json.set! :start, event.local_iso8601(event.start)
- json.set! :end, event.local_iso8601(event.end)
json.online(event.online? || event.hybrid?)
json.partial! 'common/ontology_terms', type: 'scientific_topics', resource: event
diff --git a/app/views/events/show.json.jbuilder b/app/views/events/show.json.jbuilder
index 38285bc38..1a20202bf 100644
--- a/app/views/events/show.json.jbuilder
+++ b/app/views/events/show.json.jbuilder
@@ -5,7 +5,7 @@ fields = [
:keywords, :event_types, :fields,
- :start, :end, :duration, :timezone, :timezone_inferred, :language,
+ :start, :end, :duration, :timezone, :language,
:organizer, :sponsors, :contact, :host_institutions,
@@ -22,9 +22,6 @@ fields = [
fields += Event::SENSITIVE_FIELDS if policy(@event).view_report?
json.extract! @event, *fields
-# Local time with the offset of the event's time zone, not the stored wall clock time as UTC
-json.set! :start, @event.local_iso8601(@event.start)
-json.set! :end, @event.local_iso8601(@event.end)
json.online(@event.online? || @event.hybrid?)
json.partial! 'common/ontology_terms', type: 'scientific_topics', resource: @event
diff --git a/test/controllers/events_controller_test.rb b/test/controllers/events_controller_test.rb
index cfdc73c24..ef1701589 100644
--- a/test/controllers/events_controller_test.rb
+++ b/test/controllers/events_controller_test.rb
@@ -1778,39 +1778,28 @@ class EventsControllerTest < ActionController::TestCase
assert_response :forbidden
end
- test 'should give start and end with the offset of the event timezone in json and json-api' do
+ 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 }
- json = JSON.parse(response.body)
- assert_equal '2026-10-22T09:00:00-03:00', json['start']
- assert_equal '2026-10-22T17:00:00-03:00', json['end']
-
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 and json-api when timezone is unknown' do
+ 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 }
- assert_equal '2026-10-22T09:00:00', JSON.parse(response.body)['start']
-
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 include timezone in json event list' do
- @event.update_columns(timezone: 'Buenos Aires', timezone_inferred: true, start: 1.day.from_now.change(hour: 9),
- end: 1.day.from_now.change(hour: 17))
+ 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 :index, params: { format: :json }
- event = JSON.parse(response.body).detect { |e| e['id'] == @event.id }
- assert_equal 'Buenos Aires', event['timezone']
- assert event['timezone_inferred']
- assert_match(/T09:00:00-03:00\z/, event['start'])
- assert_valid_legacy_json_response
+ 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
From 0dd96fae18a3828276645029cf270f6d21b0f59f Mon Sep 17 00:00:00 2001
From: Stian Soiland-Reyes
Date: Thu, 22 Oct 2026 23:48:48 +0000
Subject: [PATCH 05/10] Store iCalendar event times as local time, like other
events
TeSS stores event times as local (wall clock) time, with the zone in
the event's timezone. The iCalendar, Indico and Gray Scott ingestors
stored DTSTART;TZID=... times converted to UTC instead, so e.g. a
09:45 event in Perth was shown as 01:45 Perth time, and exported to
iCal and Google Calendar 8 hours early. All-day dates became midnight
in the server's time zone. Store the local time of the calendar
(Ingestor#ical_local_time). Upcoming events are corrected when their
source is ingested again.
The ingestor tests now check the actual UTC time (start_utc) and the
local time with its offset, rather than the stored column.
rake tess:report_ical_event_times lists, per iCalendar, Indico and
Gray Scott source, how many upcoming and past events are affected.
Co-Authored-By: Claude Opus 5.5
---
.../heptraining/gray_scott_ingestor.rb | 4 +--
lib/ingestors/ical_ingestor.rb | 4 +--
lib/ingestors/indico_ingestor.rb | 4 +--
lib/ingestors/ingestor.rb | 10 ++++++
lib/tasks/tess.rake | 16 +++++++++
.../heptraining/gray_scott_ingestor_test.rb | 7 ++--
test/unit/ingestors/ical_ingestor_test.rb | 8 +++--
test/unit/ingestors/ingestor_test.rb | 36 +++++++++++++++++++
8 files changed, 78 insertions(+), 11 deletions(-)
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 ff634af11..04d0343eb 100644
--- a/lib/tasks/tess.rake
+++ b/lib/tasks/tess.rake
@@ -223,6 +223,22 @@ namespace :tess do
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/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
From 24f910c05838821982ceccb192edd4061b5d307f Mon Sep 17 00:00:00 2001
From: Stian Soiland-Reyes
Date: Thu, 22 Oct 2026 23:48:48 +0000
Subject: [PATCH 06/10] Do not depend on the server's time zone for new event
times and calendar
The new event form defaulted to 09:00-17:00 in the server's system
time zone (DateTime.now), giving e.g. 00:00-08:00 on a server in
Tokyo, and the event calendar used the server's date (Date.today).
Use the application time zone (Time.zone.now, Date.current).
Co-Authored-By: Claude Opus 5.5
---
app/controllers/events_controller.rb | 8 ++++----
test/controllers/events_controller_test.rb | 12 ++++++++++++
2 files changed, 16 insertions(+), 4 deletions(-)
diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb
index aef182f8f..330c5260e 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,8 @@ 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=..
diff --git a/test/controllers/events_controller_test.rb b/test/controllers/events_controller_test.rb
index ef1701589..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
From df501ecee5e18877cd35af4344d9c47b2aea3771 Mon Sep 17 00:00:00 2001
From: Stian Soiland-Reyes
Date: Fri, 23 Oct 2026 00:09:05 +0000
Subject: [PATCH 07/10] Export events without a time zone in local time, and
mark default times
Events without a time zone were exported to iCal and Google Calendar as
UTC, so e.g. a 09:00-17:00 event in Australia appeared from 20:00 to
04:00 the next day. Export them as floating local times
(DTSTART:20261105T090000), which calendars show at the same clock time
and day in their own time zone, assuming people are near the event. The
Google Calendar link now gives the IANA time zone (ctz=Australia/Perth
rather than Perth).
Events given only dates get 09:00-17:00 by default. Mark these default
times with 1 microsecond (09:00:00.000001, not shown anywhere) so they
can be told apart from real 09:00-17:00 events (Event#default_times?),
and do not infer a time zone for them: combining the guessed times with
a guessed time zone would pin them to a time and day that may not apply,
while their floating local times show on the right day. An inferred
time zone is cleared if the times become default again, e.g. when the
event is ingested again from a source that only gives dates.
Co-Authored-By: Claude Opus 5.5
---
app/helpers/events_helper.rb | 7 ++-
app/models/event.rb | 35 ++++++++++--
lib/tasks/tess.rake | 2 +
test/helpers/events_helper_test.rb | 16 ++++++
test/models/event_test.rb | 57 +++++++++++++++++++
.../ingestors/carpentries_ingestor_test.rb | 4 +-
.../taxila/citizen_science_ingestor_test.rb | 4 +-
.../ingestors/taxila/dans_ingestor_test.rb | 4 +-
.../ingestors/taxila/dcc_ingestor_test.rb | 4 +-
.../ingestors/taxila/dtls_ingestor_test.rb | 4 +-
.../ingestors/taxila/han_ingestor_test.rb | 4 +-
.../ingestors/taxila/nwo_ingestor_test.rb | 4 +-
.../ingestors/taxila/oscd_ingestor_test.rb | 4 +-
.../ingestors/taxila/rdnl_ingestor_test.rb | 4 +-
.../ingestors/taxila/rug_ingestor_test.rb | 4 +-
.../ingestors/taxila/tdcc_ingestor_test.rb | 4 +-
.../taxila/uhasselt_ingestor_test.rb | 4 +-
.../ingestors/taxila/wur_ingestor_test.rb | 4 +-
test/workers/geocoding_worker_test.rb | 18 +++++-
19 files changed, 152 insertions(+), 35 deletions(-)
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 35e243d94..9dded73d6 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -31,7 +31,9 @@ class Event < ApplicationRecord
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?
- before_save :timezone_from_location, if: -> { timezone.blank? }
+ # Not for date-only events with default times: their floating local times show on the right day anywhere
+ before_save -> { self.timezone, self.timezone_inferred = nil, false }, 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
@@ -177,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
@@ -203,10 +207,21 @@ def local_iso8601(time)
return nil if time.blank?
wall_clock = time.strftime('%Y-%m-%dT%H:%M:%S')
- zone = ActiveSupport::TimeZone[timezone] if timezone.present?
+ 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
@@ -272,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
@@ -302,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 == 0
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 == 0
elsif online?
self.end = start + 1.hour
else
@@ -400,7 +422,8 @@ def address_changed?
# 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.
+ # 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?
diff --git a/lib/tasks/tess.rake b/lib/tasks/tess.rake
index 04d0343eb..8b8975d1a 100644
--- a/lib/tasks/tess.rake
+++ b/lib/tasks/tess.rake
@@ -212,6 +212,8 @@ namespace :tess do
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
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/models/event_test.rb b/test/models/event_test.rb
index 92af6a4fe..b03589063 100644
--- a/test/models/event_test.rb
+++ b/test/models/event_test.rb
@@ -401,6 +401,48 @@ class EventTest < ActiveSupport::TestCase
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'))
@@ -428,6 +470,21 @@ class EventTest < ActiveSupport::TestCase
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/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/workers/geocoding_worker_test.rb b/test/workers/geocoding_worker_test.rb
index 85eb418ee..6cf86539b 100644
--- a/test/workers/geocoding_worker_test.rb
+++ b/test/workers/geocoding_worker_test.rb
@@ -38,7 +38,7 @@ class GeocodingWorkerTest < ActiveSupport::TestCase
mock_nominatim
event = events(:kilburn)
- event.update_column(:timezone, nil)
+ 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])
@@ -47,6 +47,22 @@ class GeocodingWorkerTest < ActiveSupport::TestCase
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
From 5653a425359e8c20ed286812114a1140b4f0876e Mon Sep 17 00:00:00 2001
From: Stian Soiland-Reyes
Date: Fri, 23 Oct 2026 00:12:36 +0000
Subject: [PATCH 08/10] Document how event times and time zones are stored
A short developer note on local (wall clock) storage with a separate
time zone, exports, the 1 microsecond marker on default times,
inferred time zones, and what ingestors and their tests need to do.
Co-Authored-By: Claude Opus 5.5
---
README.md | 4 ++++
docs/timezones.md | 43 +++++++++++++++++++++++++++++++++++++++++++
2 files changed, 47 insertions(+)
create mode 100644 docs/timezones.md
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/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.
From 87f36f3e16c59defd971385f40f8c47f1f745e1d Mon Sep 17 00:00:00 2001
From: Stian Soiland-Reyes
Date: Fri, 23 Oct 2026 00:17:54 +0000
Subject: [PATCH 09/10] Tidy up time zone code for review
Use the Rails 8.1 migration version like the other migrations, address
rubocop remarks on the changed code (simpler TimezoneLookup.rails_name
and timezone_suggestion, no parallel assignment, zero?, frozen string
literals), and test the lookup failure paths.
Co-Authored-By: Claude Opus 5.5
---
app/controllers/events_controller.rb | 19 +++++++++++--------
app/models/event.rb | 11 ++++++++---
...2230000_add_timezone_inferred_to_events.rb | 2 +-
lib/timezone_lookup.rb | 16 ++++++++++------
test/unit/timezone_lookup_test.rb | 13 +++++++++++++
5 files changed, 43 insertions(+), 18 deletions(-)
diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb
index 330c5260e..3f1e36ca4 100644
--- a/app/controllers/events_controller.rb
+++ b/app/controllers/events_controller.rb
@@ -104,14 +104,7 @@ def timezone_suggestion
authorize Event, :new?
suggestion = TimezoneLookup.find(latitude: params[:latitude], longitude: params[:longitude],
country: params[:country])
- selected = params[:timezone].presence
- message = if suggestion.nil? || (selected && TimezoneLookup.same_offsets?(selected, suggestion))
- nil
- elsif selected
- t('events.timezone_suggestion.mismatch', suggestion:, selected:)
- else
- t('events.timezone_suggestion.blank', suggestion:)
- end
+ message = timezone_suggestion_message(suggestion, params[:timezone].presence)
render json: { timezone: suggestion, message:, button: (t('events.timezone_suggestion.use', suggestion:) if message) }
end
@@ -246,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/models/event.rb b/app/models/event.rb
index 9dded73d6..a3522d5eb 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -32,7 +32,7 @@ class Event < ApplicationRecord
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 -> { self.timezone, self.timezone_inferred = nil, false }, if: -> { timezone_inferred? && default_times? }
+ 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?
@@ -324,10 +324,10 @@ def set_default_times
# 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 == 0
+ self.start = (start + 9.hours).change(usec: DEFAULT_TIME_MARKER) if start.hour.zero?
if self.end
- self.end = (self.end + 17.hours).change(usec: DEFAULT_TIME_MARKER) if self.end.hour == 0
+ self.end = (self.end + 17.hours).change(usec: DEFAULT_TIME_MARKER) if self.end.hour.zero?
elsif online?
self.end = start + 1.hour
else
@@ -429,6 +429,11 @@ def timezone_from_location
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/db/migrate/20261022230000_add_timezone_inferred_to_events.rb b/db/migrate/20261022230000_add_timezone_inferred_to_events.rb
index 33b10a84a..ace78f001 100644
--- a/db/migrate/20261022230000_add_timezone_inferred_to_events.rb
+++ b/db/migrate/20261022230000_add_timezone_inferred_to_events.rb
@@ -1,4 +1,4 @@
-class AddTimezoneInferredToEvents < ActiveRecord::Migration[7.2]
+class AddTimezoneInferredToEvents < ActiveRecord::Migration[8.1]
def change
add_column :events, :timezone_inferred, :boolean, default: false, null: false
end
diff --git a/lib/timezone_lookup.rb b/lib/timezone_lookup.rb
index 43d7f8544..2a94a3dc5 100644
--- a/lib/timezone_lookup.rb
+++ b/lib/timezone_lookup.rb
@@ -1,3 +1,5 @@
+# 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)").
#
@@ -55,19 +57,21 @@ 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)
- return name if name
+ name || nearest_with_same_offsets(canonical, *(latitude && longitude ? [latitude, longitude] : locations[canonical]))
+ rescue TZInfo::InvalidTimezoneIdentifier
+ nil
+ end
- offsets = offsets(canonical)
- candidates = mapping.select { |_, id| offsets(id) == offsets }.keys
- latitude, longitude = locations[canonical] if latitude.nil? || longitude.nil?
+ # 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
- rescue TZInfo::InvalidTimezoneIdentifier
- nil
end
# The Rails name for an IANA time zone, preferring the one named after its city when there are several
diff --git a/test/unit/timezone_lookup_test.rb b/test/unit/timezone_lookup_test.rb
index 1141b7d71..1f3d30653 100644
--- a/test/unit/timezone_lookup_test.rb
+++ b/test/unit/timezone_lookup_test.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
require 'test_helper'
class TimezoneLookupTest < ActiveSupport::TestCase
@@ -43,4 +45,15 @@ class TimezoneLookupTest < ActiveSupport::TestCase
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
From bb44be60f6a495d18de60be9aa4ef1f40c85e5bd Mon Sep 17 00:00:00 2001
From: Stian Soiland-Reyes
Date: Fri, 23 Oct 2026 00:27:26 +0000
Subject: [PATCH 10/10] Adding myself
---
CONTRIBUTORS.md | 1 +
1 file changed, 1 insertion(+)
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