From f048ecd8f3d6114852ac7fea41b00cc9ba0e322a Mon Sep 17 00:00:00 2001 From: Stian Soiland-Reyes Date: Fri, 23 Oct 2026 00:38:43 +0000 Subject: [PATCH 1/8] Give event times in ISO 8601 with the event's offset in Bioschemas and JSON-API TeSS stores an event's start and end as local (wall clock) time in a UTC column, with the zone in timezone. Bioschemas startDate/endDate gave that time with Time#to_s ("2026-10-22 09:00:00 UTC", not ISO 8601) and JSON-API as "2026-10-22T09:00:00.000Z", both presenting the local time as UTC: an event at 09:00 in Buenos Aires was given as 09:00 UTC rather than 12:00 UTC. Give them in ISO 8601 with the offset of the event's time zone on that date (Event#local_iso8601), e.g. "2026-10-22T09:00:00-03:00", or without an offset, as local time, when the time zone is unknown. The legacy JSON API is left unchanged. See #1391 Co-Authored-By: Claude Opus 5.5 --- app/models/event.rb | 16 +++++++++++++ app/serializers/event_serializer.rb | 9 +++++++ lib/bioschemas/course_instance_generator.rb | 4 ++-- lib/bioschemas/event_generator.rb | 4 ++-- lib/bioschemas/generator.rb | 5 ++++ public/api/definitions/tess.yml | 6 +++-- test/controllers/events_controller_test.rb | 25 ++++++++++++++++++++ test/integration/bioschemas_test.rb | 26 +++++++++++++++++---- 8 files changed, 85 insertions(+), 10 deletions(-) diff --git a/app/models/event.rb b/app/models/event.rb index 030b25f10..ae53ce8b3 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -194,6 +194,22 @@ def start_local set_to_local start end + # A start or end time as an ISO 8601 date-time with the UTC offset of the event's time zone on that date, e.g. + # "2015-11-23T10:16:33-05:00". Times are stored as local (wall clock) time, so without a known time zone (e.g. + # many online events) it is given without an offset, as local time, rather than as UTC. + def local_iso8601(time) + return nil if time.blank? + + wall_clock = time.strftime('%Y-%m-%dT%H:%M:%S') + zone = known_time_zone + zone ? zone.parse(wall_clock).iso8601 : wall_clock + end + + # 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 diff --git a/app/serializers/event_serializer.rb b/app/serializers/event_serializer.rb index 687e857ae..1cb621a97 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/lib/bioschemas/course_instance_generator.rb b/lib/bioschemas/course_instance_generator.rb index aad08b67e..5e34817c9 100644 --- a/lib/bioschemas/course_instance_generator.rb +++ b/lib/bioschemas/course_instance_generator.rb @@ -8,8 +8,8 @@ def self.bioschemas_profile 'https://bioschemas.org/profiles/CourseInstance/1.0-RELEASE' end - property :startDate, :start - property :endDate, :end + property :startDate, -> (event) { local_time(event, event.start) } + property :endDate, -> (event) { local_time(event, event.end) } property :organizer, -> (event) { { '@type' => 'Organization', name: event.organizer } if event.organizer.present? } property :location, -> (event) { address(event) }, condition: -> (event) { event.venue.present? || event.city.present? || event.county.present? || diff --git a/lib/bioschemas/event_generator.rb b/lib/bioschemas/event_generator.rb index 3722480a7..892bc935d 100644 --- a/lib/bioschemas/event_generator.rb +++ b/lib/bioschemas/event_generator.rb @@ -13,8 +13,8 @@ def self.bioschemas_profile property :url, :url property :description, :description property :keywords, :keywords - property :startDate, :start - property :endDate, :end + property :startDate, -> (event) { local_time(event, event.start) } + property :endDate, -> (event) { local_time(event, event.end) } property :organizer, -> (event) { { '@type' => 'Organization', name: event.organizer } if event.organizer.present? } property :location, -> (event) { address(event) }, condition: -> (event) { event.venue.present? || event.city.present? || event.county.present? || diff --git a/lib/bioschemas/generator.rb b/lib/bioschemas/generator.rb index 046bba106..a5d743743 100644 --- a/lib/bioschemas/generator.rb +++ b/lib/bioschemas/generator.rb @@ -79,6 +79,11 @@ def self.term(term) } end + # An event's start or end time in ISO 8601, with the offset of the event's time zone (see Event#local_iso8601) + def self.local_time(event, time) + event.local_iso8601(time) + end + def self.person(person) p = { "@type" => "Person", diff --git a/public/api/definitions/tess.yml b/public/api/definitions/tess.yml index 40255dd59..2bf491d06 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 984f6a0a6..3bab92e82 100644 --- a/test/controllers/events_controller_test.rb +++ b/test/controllers/events_controller_test.rb @@ -1719,4 +1719,29 @@ class EventsControllerTest < ActionController::TestCase assert_select '#origin-info a[href=?]', uri end + + test 'should give start and end with the offset of the event timezone in json-api' do + @event.update_columns(timezone: 'Buenos Aires', start: Time.utc(2026, 10, 22, 9), end: Time.utc(2026, 10, 22, 17)) + + get :show, params: { id: @event, format: :json_api } + attributes = JSON.parse(response.body)['data']['attributes'] + assert_equal '2026-10-22T09:00:00-03:00', attributes['start'] + assert_equal '2026-10-22T17:00:00-03:00', attributes['end'] + end + + test 'should give start and end without offset in json-api when timezone is unknown' do + @event.update_columns(timezone: nil, start: Time.utc(2026, 10, 22, 9), end: Time.utc(2026, 10, 22, 17)) + + get :show, params: { id: @event, format: :json_api } + assert_equal '2026-10-22T09:00:00', JSON.parse(response.body)['data']['attributes']['start'] + end + + test 'should leave start and end in the legacy json api unchanged' do + @event.update_columns(timezone: 'Buenos Aires', start: Time.utc(2026, 10, 22, 9), end: Time.utc(2026, 10, 22, 17)) + + get :show, params: { id: @event, format: :json } + json = JSON.parse(response.body) + assert_equal '2026-10-22T09:00:00.000Z', json['start'] + assert_nil json['timezone_inferred'] + end end diff --git a/test/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 1e1b18f0f3d78e92f42a6fbe01704d8334b96ba9 Mon Sep 17 00:00:00 2001 From: Stian Soiland-Reyes Date: Fri, 23 Oct 2026 00:40:14 +0000 Subject: [PATCH 2/8] 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. 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 ++++++ .../heptraining/gray_scott_ingestor_test.rb | 7 ++-- test/unit/ingestors/ical_ingestor_test.rb | 8 +++-- test/unit/ingestors/ingestor_test.rb | 36 +++++++++++++++++++ 7 files changed, 62 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/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 4206310bf6bdca53d290d795f31508220eb96aad Mon Sep 17 00:00:00 2001 From: Stian Soiland-Reyes Date: Fri, 23 Oct 2026 00:41:06 +0000 Subject: [PATCH 3/8] 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 bdfe153fb..fc6ecbb0f 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/1/clone diff --git a/test/controllers/events_controller_test.rb b/test/controllers/events_controller_test.rb index 3bab92e82..6f63b42d3 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 e14f3fee61c5a7067355f061171c607e30a43740 Mon Sep 17 00:00:00 2001 From: Stian Soiland-Reyes Date: Fri, 23 Oct 2026 00:42:02 +0000 Subject: [PATCH 4/8] Export events without a time zone in local time 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). Co-Authored-By: Claude Opus 5.5 --- app/helpers/events_helper.rb | 7 +++++-- app/models/event.rb | 7 ++++++- test/helpers/events_helper_test.rb | 16 ++++++++++++++++ test/models/event_test.rb | 15 +++++++++++++++ 4 files changed, 42 insertions(+), 3 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 ae53ce8b3..b21893c2c 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -275,9 +275,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 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 55ae09150..057883969 100644 --- a/test/models/event_test.rb +++ b/test/models/event_test.rb @@ -387,6 +387,21 @@ class EventTest < ActiveSupport::TestCase end 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? From 961b54404f8477e1f06fac916a2acc517f5d0757 Mon Sep 17 00:00:00 2001 From: Stian Soiland-Reyes Date: Fri, 23 Oct 2026 00:42:17 +0000 Subject: [PATCH 5/8] Document how event times and time zones are stored A short developer note on local (wall clock) storage with a separate time zone, exports, default times, and what ingestors and their tests need to do. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++++ docs/timezones.md | 29 +++++++++++++++++++++++++++++ 2 files changed, 33 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..66e2198bb --- /dev/null +++ b/docs/timezones.md @@ -0,0 +1,29 @@ +# 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. +- Events given only dates (start and end at midnight) get 09:00–17:00 (`Event#set_default_times`). +- Nothing should depend on the server's time zone: use `Time.zone.now` and `Date.current`, not `Time.now`, + `DateTime.now` or `Date.today`. + +## 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. `mock_timezone` runs a test in + another server time zone, which should not change the result. From 7ffd2b7945fda6d153d983f0401328f4564fd491 Mon Sep 17 00:00:00 2001 From: Stian Soiland-Reyes Date: Fri, 23 Oct 2026 00:27:26 +0000 Subject: [PATCH 6/8] 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 From 85b217465b1921d3d243fd81aa65bcf766838f91 Mon Sep 17 00:00:00 2001 From: Stian Soiland-Reyes Date: Fri, 23 Oct 2026 00:52:37 +0000 Subject: [PATCH 7/8] 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 JSON-API, until it is changed, e.g. by the user. Events given only dates get 09:00-17:00 by default; these are now marked with a new time_inferred column, kept when the event is saved again with the same times (e.g. from the form), and cleared when a time of day is given. No time zone is inferred for them, as a guessed time zone on top of guessed times 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 inferred again, e.g. when the event is ingested again from a source that only gives dates. Existing events get a time zone when they are next saved, e.g. when their source is next ingested. See #1391 Co-Authored-By: Claude Opus 5.5 --- Gemfile | 1 + Gemfile.lock | 2 + app/models/concerns/log_parameter_changes.rb | 2 +- app/models/event.rb | 26 ++++- app/serializers/event_serializer.rb | 2 +- app/views/events/show.html.erb | 2 +- config/locales/en.yml | 1 + ...2230000_add_timezone_inferred_to_events.rb | 6 + db/schema.rb | 4 +- docs/timezones.md | 11 +- lib/timezone_lookup.rb | 107 ++++++++++++++++++ public/api/definitions/tess.yml | 7 ++ test/controllers/events_controller_test.rb | 16 +++ test/models/event_test.rb | 99 ++++++++++++++++ test/unit/timezone_lookup_test.rb | 59 ++++++++++ test/workers/geocoding_worker_test.rb | 29 +++++ 16 files changed, 368 insertions(+), 6 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/models/concerns/log_parameter_changes.rb b/app/models/concerns/log_parameter_changes.rb index be0956fb9..49641b4c7 100644 --- a/app/models/concerns/log_parameter_changes.rb +++ b/app/models/concerns/log_parameter_changes.rb @@ -2,7 +2,7 @@ module LogParameterChanges extend ActiveSupport::Concern - IGNORED_ATTRIBUTES = ['id', 'updated_at', 'last_scraped', 'remote_updated_date'] + IGNORED_ATTRIBUTES = ['id', 'updated_at', 'last_scraped', 'remote_updated_date', 'time_inferred', 'timezone_inferred'] included do after_update :log_parameter_changes diff --git a/app/models/event.rb b/app/models/event.rb index b21893c2c..38f0eed95 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -30,6 +30,10 @@ class Event < ApplicationRecord before_save :check_country_name before_save :set_default_times before_save :geocoding_cache_lookup, if: :address_will_change? + before_save -> { self.timezone_inferred = false }, if: :will_save_change_to_timezone? + # Not for events with default times: their floating local times show on the right day anywhere + before_save :clear_inferred_timezone, if: -> { timezone_inferred? && time_inferred? } + before_save :timezone_from_location, if: -> { timezone.blank? && !time_inferred? } after_save :enqueue_geocoding_worker, if: :address_changed? if TeSS::Config.solr_enabled @@ -310,7 +314,13 @@ 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 as inferred + if start.hour.zero? + self.start = start + 9.hours + self.time_inferred = true + elsif start_in_database && start.strftime('%H:%M') != start_in_database.strftime('%H:%M') + self.time_inferred = false # A time of day was given + end if self.end self.end = self.end + 17.hours if self.end.hour == 0 # hour set to 0 if not otherwise defined... @@ -406,6 +416,20 @@ def address_changed? ADDRESS_FIELDS.any? { |field| previous_changes.keys.include?(field.to_s) } end + # Set a missing time zone from the event's coordinates (once geocoded) or, failing that, its country. Online + # events often have neither, and keep no time zone. Marked as inferred until the time zone is changed otherwise, + # e.g. by the user. Not done for events with default (inferred) times, as combining two guesses would pin them to + # a time and day that may not apply. + def timezone_from_location + self.timezone = TimezoneLookup.find(latitude:, longitude:, country:) + self.timezone_inferred = timezone.present? + end + + def clear_inferred_timezone + self.timezone = nil + self.timezone_inferred = false + end + # Check the Redis cache for coordinates def geocoding_cache_lookup location = address diff --git a/app/serializers/event_serializer.rb b/app/serializers/event_serializer.rb index 1cb621a97..ae49896df 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, :time_inferred, :organizer, :sponsors, :contact, :host_institutions, diff --git a/app/views/events/show.html.erb b/app/views/events/show.html.erb index e263ed2b8..4f23e630d 100644 --- a/app/views/events/show.html.erb +++ b/app/views/events/show.html.erb @@ -63,7 +63,7 @@ Date: <%= neatly_printed_date_range(@event.start, @event.end) %>

- <%= display_attribute(@event, :timezone) %> + <%= display_attribute(@event, :timezone) { |tz| @event.timezone_inferred? ? t('events.timezone_inferred', timezone: tz) : tz } %> <%= display_attribute(@event, :duration) %> <%= display_attribute(@event, :language) { |value| render_language_name(value) } %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 2cb64b934..bfb450860 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -634,6 +634,7 @@ 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)" hints: address: 'Physical location of the event if its being conducted Face-to-face.' capacity: > 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..0390c38fb --- /dev/null +++ b/db/migrate/20261022230000_add_timezone_inferred_to_events.rb @@ -0,0 +1,6 @@ +class AddTimezoneInferredToEvents < ActiveRecord::Migration[8.1] + def change + add_column :events, :timezone_inferred, :boolean, default: false, null: false + add_column :events, :time_inferred, :boolean, default: false, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index ed474f683..e4cccadd3 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" @@ -227,7 +227,9 @@ t.string "subtitle" t.string "target_audience", default: [], array: true t.text "tech_requirements" + t.boolean "time_inferred", default: false, null: false 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/timezones.md b/docs/timezones.md index 66e2198bb..de66ddb8d 100644 --- a/docs/timezones.md +++ b/docs/timezones.md @@ -13,10 +13,19 @@ columns are UTC `datetime`s, so an event at 09:00–17:00 in Buenos Aires is sto 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. -- Events given only dates (start and end at midnight) get 09:00–17:00 (`Event#set_default_times`). +- Events given only dates (start and end at midnight) get 09:00–17:00 (`Event#set_default_times`), and are marked + with `time_inferred` until a time of day is given. - Nothing should depend on the server's time zone: use `Time.zone.now` and `Date.current`, not `Time.now`, `DateTime.now` or `Date.today`. +## 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 inferred (default) times, as a guessed time zone on top of guessed times would pin them to a +time and day that may not apply. Existing events get one when they are next saved, e.g. when their source is next +ingested. + ## Ingestors - Assign the **local time of the source**, and set `timezone` if the source gives one. Do not assign a time with diff --git a/lib/timezone_lookup.rb b/lib/timezone_lookup.rb new file mode 100644 index 000000000..2a94a3dc5 --- /dev/null +++ b/lib/timezone_lookup.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +# Finds the time zone of a place, as one of the time zone names TeSS accepts (the keys of +# ActiveSupport::TimeZone::MAPPING, e.g. "Eastern Time (US & Canada)"). +# +# Coordinates are looked up offline in the timezone-boundary-builder polygons bundled with the wheretz gem. Without +# coordinates, a country with a single time zone gives that zone. +module TimezoneLookup + # Sample times over a year, to compare the UTC offsets (including daylight saving time) of two time zones + SAMPLES = (0..23).map { |i| Time.utc(Time.now.year, 1, 1) + (i * 365.25 / 24).days }.freeze + + # Returns a TeSS time zone name, or nil if none can be found. + def self.find(latitude: nil, longitude: nil, country: nil) + if latitude.present? && longitude.present? + zone = zone_at(latitude.to_f, longitude.to_f) + return rails_name(zone, latitude.to_f, longitude.to_f) if zone + end + + zone = country_zone(country) + rails_name(zone) if zone + end + + # Whether two TeSS time zone names (or IANA identifiers) have the same UTC offsets over the year, e.g. "London" + # and "Edinburgh". False if either is unknown. + def self.same_offsets?(zone_a, zone_b) + ids = [zone_a, zone_b].map { |zone| ActiveSupport::TimeZone[zone.to_s]&.tzinfo&.identifier } + return false if ids.any?(&:nil?) + + offsets(ids[0]) == offsets(ids[1]) + end + + # The IANA time zone at a point, or nil if there is none or the point is on a border between time zones. + def self.zone_at(latitude, longitude) + zone = WhereTZ.lookup(latitude, longitude) + zone.is_a?(String) ? zone : nil + rescue ArgumentError + nil + end + + # The main IANA time zone of a country (name or ISO 3166 code), if all its time zones have the same UTC offsets + # (e.g. Germany's Europe/Berlin and Europe/Busingen). + def self.country_zone(country) + return nil if country.blank? + + iso = ISO3166::Country[country] || ISO3166::Country.find_country_by_any_name(country) + return nil unless iso + + zones = TZInfo::Country.get(iso.alpha2).zone_identifiers + zones.first if zones.map { |zone| offsets(zone) }.uniq.one? + rescue TZInfo::InvalidCountryCode + nil + end + + # The Rails time zone name for an IANA time zone: its own name if Rails has one, otherwise the Rails time zone + # with the same UTC offsets over the year that is nearest to the given point (or to the zone's main city). + def self.rails_name(zone, latitude = nil, longitude = nil) + canonical = TZInfo::Timezone.get(zone).canonical_identifier + mapping = ActiveSupport::TimeZone::MAPPING + name = [zone, canonical].lazy.map { |id| mapping.key?(id) ? id : named_after_city(id) }.find(&:itself) + name || nearest_with_same_offsets(canonical, *(latitude && longitude ? [latitude, longitude] : locations[canonical])) + rescue TZInfo::InvalidTimezoneIdentifier + nil + end + + # The Rails time zone with the same UTC offsets over the year as an IANA time zone that is nearest to a point + def self.nearest_with_same_offsets(zone, latitude = nil, longitude = nil) + mapping = ActiveSupport::TimeZone::MAPPING + candidates = mapping.select { |_, id| offsets(id) == offsets(zone) }.keys + return candidates.first if latitude.nil? || longitude.nil? + + candidates.min_by do |candidate| + location = locations[TZInfo::Timezone.get(mapping[candidate]).canonical_identifier] + location ? distance(latitude, longitude, *location) : Float::INFINITY + end + end + + # The Rails name for an IANA time zone, preferring the one named after its city when there are several + # (e.g. "London" rather than "Edinburgh" for Europe/London). + def self.named_after_city(zone) + names = ActiveSupport::TimeZone::MAPPING.select { |_, id| id == zone }.keys + city = zone.split('/').last.tr('_', ' ') + names.find { |name| name == city } || names.first + end + + def self.offsets(zone) + @offsets ||= {} + @offsets[zone] ||= begin + tz = TZInfo::Timezone.get(zone) + SAMPLES.map { |time| tz.period_for_utc(time).observed_utc_offset } + end + end + + # The location of the main city of each IANA time zone, from tzinfo's zone.tab + def self.locations + @locations ||= TZInfo::Country.all.flat_map(&:zone_info).to_h do |zone| + [zone.identifier, [zone.latitude.to_f, zone.longitude.to_f]] + end + end + + # Angle between two points on a sphere (relative distance is all that is needed) + def self.distance(lat1, lon1, lat2, lon2) + rad = Math::PI / 180 + cos = (Math.sin(lat1 * rad) * Math.sin(lat2 * rad)) + + (Math.cos(lat1 * rad) * Math.cos(lat2 * rad) * Math.cos((lon1 - lon2) * rad)) + Math.acos(cos.clamp(-1, 1)) + end +end diff --git a/public/api/definitions/tess.yml b/public/api/definitions/tess.yml index 2bf491d06..80eb8b4d2 100644 --- a/public/api/definitions/tess.yml +++ b/public/api/definitions/tess.yml @@ -1093,6 +1093,13 @@ 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. + time-inferred: + type: boolean + description: Whether the start and end times are defaults (09:00-17:00), as only dates were given. 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 6f63b42d3..cd83a8d93 100644 --- a/test/controllers/events_controller_test.rb +++ b/test/controllers/events_controller_test.rb @@ -1756,4 +1756,20 @@ class EventsControllerTest < ActionController::TestCase assert_equal '2026-10-22T09:00:00.000Z', json['start'] assert_nil json['timezone_inferred'] 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 + end diff --git a/test/models/event_test.rb b/test/models/event_test.rb index 057883969..b3685829e 100644 --- a/test/models/event_test.rb +++ b/test/models/event_test.rb @@ -387,6 +387,105 @@ class EventTest < ActiveSupport::TestCase end end + test 'sets missing timezone from coordinates' do + event = Event.create!(@mandatory.merge(user: users(:regular_user), title: 'Event in Evansville', + url: 'http://example.com', timezone: nil, + latitude: 37.97, longitude: -87.57, venue: 'Somewhere')) + assert_equal 'Central Time (US & Canada)', event.timezone + assert event.valid? + end + + test 'sets missing timezone from country with a single timezone' do + event = Event.create!(@mandatory.merge(user: users(:regular_user), title: 'Online event', url: 'http://example.com', + timezone: nil, presence: :online, country: 'United Kingdom')) + assert_equal 'London', event.timezone + end + + test 'marks default times of date-only events as inferred' 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], [event.start.hour, event.start.min] + assert_equal [17, 0], [event.end.hour, event.end.min] + assert event.time_inferred? + assert_includes event.to_ical, "DTSTART:20261105T090000\r\n" + end + + test 'keeps times inferred when saved from the form unchanged, or moved to another day' 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')) + # The form submits the times at minute precision, as shown + event.update!(title: 'Edited', start: '2026-11-05 09:00', end: '2026-11-05 17:00', timezone: '') + assert event.time_inferred? + assert event.timezone.blank?, 'Should not infer a time zone for default times' + + event.update!(start: '2026-11-12 09:00', end: '2026-11-12 17:00') + assert event.time_inferred? + + event.update!(start: '2026-11-12 10:00') + refute event.time_inferred?, 'A time of day was given' + assert_equal 'Melbourne', event.timezone + 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.time_inferred? + assert_equal 'Melbourne', event.timezone + end + + test 'clears inferred timezone when times become default, but not a chosen one' do + event = Event.create!(@mandatory.merge(user: users(:regular_user), title: 'Event', url: 'http://example.com', + timezone: nil, start: Time.utc(2026, 11, 5, 9), end: Time.utc(2026, 11, 5, 17), + latitude: -37.81, longitude: 144.96, venue: 'Somewhere')) + assert event.timezone_inferred? + + # e.g. ingested again from a source that only gives the date + event.update!(start: Date.new(2026, 11, 5), end: Date.new(2026, 11, 5)) + assert_nil event.timezone + refute event.timezone_inferred? + + event.update!(timezone: 'Melbourne') + event.update!(start: Date.new(2026, 11, 6), end: Date.new(2026, 11, 6)) + assert_equal 'Melbourne', event.timezone + end + + test 'marks inferred timezone until it is changed' do + event = Event.create!(@mandatory.merge(user: users(:regular_user), title: 'Event', url: 'http://example.com', + timezone: nil, country: 'United Kingdom', venue: 'Somewhere')) + assert_equal 'London', event.timezone + assert event.timezone_inferred? + + event.update!(title: 'Renamed event') + assert event.timezone_inferred?, 'Should still be inferred when the timezone is not changed' + + event.update!(timezone: 'Dublin') + assert_equal 'Dublin', event.timezone + refute event.timezone_inferred? + end + + test 'does not change a timezone that is set' do + event = Event.create!(@mandatory.merge(user: users(:regular_user), title: 'Event', url: 'http://example.com', + timezone: 'London', latitude: -37.81, longitude: 144.96, venue: 'Somewhere')) + assert_equal 'London', event.timezone + refute event.timezone_inferred? + end + + test 'no timezone for online event without location' do + event = Event.create!(@mandatory.merge(user: users(:regular_user), title: 'Online event', url: 'http://example.com', + timezone: nil, presence: :online)) + assert_nil event.timezone + end + test 'ical export in UTC when the timezone is known' do event = Event.new(title: 'Event', timezone: 'Perth', start: Time.utc(2022, 2, 11, 9, 45), end: Time.utc(2022, 2, 11, 12, 50)) diff --git a/test/unit/timezone_lookup_test.rb b/test/unit/timezone_lookup_test.rb new file mode 100644 index 000000000..1f3d30653 --- /dev/null +++ b/test/unit/timezone_lookup_test.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'test_helper' + +class TimezoneLookupTest < ActiveSupport::TestCase + test 'finds time zone from coordinates' do + assert_equal 'Melbourne', TimezoneLookup.find(latitude: -37.81, longitude: 144.96) + assert_equal 'Perth', TimezoneLookup.find(latitude: -31.95, longitude: 115.86) + assert_equal 'Mountain Time (US & Canada)', TimezoneLookup.find(latitude: 43.6, longitude: -116.2) # Boise + end + + test 'finds time zone from coordinates near a time zone border' do + # Evansville, Indiana is on Central time, although the nearest time zone city (Petersburg, Indiana) is Eastern + assert_equal 'Central Time (US & Canada)', TimezoneLookup.find(latitude: 37.97, longitude: -87.57) + end + + test 'prefers the time zone name of the city' do + assert_equal 'London', TimezoneLookup.find(latitude: 51.5, longitude: -0.12) + assert_equal 'London', TimezoneLookup.find(latitude: 55.95, longitude: -3.19) # Edinburgh is also Europe/London + end + + test 'gives a time zone with the same offsets when TeSS has no name for it' do + # Atlantic/Canary is not one of the Rails time zones, but has the same offsets as Lisbon + assert_equal 'Lisbon', TimezoneLookup.find(latitude: 28.12, longitude: -15.43) + end + + test 'coordinates take precedence over country' do + assert_equal 'Moscow', TimezoneLookup.find(latitude: 45, longitude: 45, country: 'United Kingdom') + end + + test 'finds time zone from country with a single time zone' do + assert_equal 'London', TimezoneLookup.find(country: 'United Kingdom') + assert_equal 'London', TimezoneLookup.find(country: 'GB') + assert_equal 'Berlin', TimezoneLookup.find(country: 'Germany') # also has Europe/Busingen, with the same offsets + end + + test 'no time zone for country with several time zones' do + assert_nil TimezoneLookup.find(country: 'United States') + assert_nil TimezoneLookup.find(country: 'Spain') # Canary Islands are an hour behind + end + + test 'no time zone without location' do + assert_nil TimezoneLookup.find + assert_nil TimezoneLookup.find(country: '') + assert_nil TimezoneLookup.find(country: 'BigCountry') + assert_nil TimezoneLookup.find(latitude: 0, longitude: -30) # Atlantic ocean + end + + test 'no time zone when the lookups fail' do + WhereTZ.stub(:lookup, ->(*) { raise ArgumentError, 'Point outside any known timezone' }) do + assert_nil TimezoneLookup.zone_at(51.5, -0.12) + end + TZInfo::Country.stub(:get, ->(*) { raise TZInfo::InvalidCountryCode }) do + assert_nil TimezoneLookup.country_zone('United Kingdom') + end + assert_nil TimezoneLookup.rails_name('Not/A_Zone') + refute TimezoneLookup.same_offsets?('London', 'Not a zone') + end +end diff --git a/test/workers/geocoding_worker_test.rb b/test/workers/geocoding_worker_test.rb index c11948254..06c54f318 100644 --- a/test/workers/geocoding_worker_test.rb +++ b/test/workers/geocoding_worker_test.rb @@ -34,6 +34,35 @@ class GeocodingWorkerTest < ActiveSupport::TestCase assert_equal 45, event.longitude end + test 'set missing timezone from geocoded coordinates' do + mock_nominatim + + event = events(:kilburn) + event.update_columns(timezone: nil, start: Time.utc(2019, 11, 23, 10), end: Time.utc(2019, 11, 23, 16)) + + Sidekiq::Testing.inline! do + GeocodingWorker.perform_async([event.id, event.address]) + end + + assert_equal 'London', event.reload.timezone + end + + test 'do not set timezone from geocoded coordinates for date-only event' do + mock_nominatim + + event = events(:kilburn) # dates only, so given the default times when saved + event.update_column(:timezone, nil) + + Sidekiq::Testing.inline! do + GeocodingWorker.perform_async([event.id, event.address]) + end + + event.reload + assert event.latitude.present? + assert event.time_inferred? + 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 b1438efa9a30938899b53d0b6bdda2fc674f4f0d Mon Sep 17 00:00:00 2001 From: Stian Soiland-Reyes Date: Fri, 23 Oct 2026 00:54:41 +0000 Subject: [PATCH 8/8] Suggest the time zone of the location in the event form The event form suggests the time zone of the location when none is selected, and warns when the selected one differs (ignoring time zones with the same offsets, e.g. Edinburgh and London), with a button to use it. This is advice only: the user can keep their own choice. It is updated when the time zone, country, city or postcode change, or the address finder sets a location; if the address fields are edited by hand after the address finder, only the country is used. Co-Authored-By: Claude Opus 5.5 --- app/assets/javascripts/events.js | 51 ++++++++++++++++++++++ app/assets/javascripts/map_search.js | 3 +- app/controllers/events_controller.rb | 21 +++++++++ app/views/events/_form.html.erb | 5 +++ config/locales/en.yml | 6 +++ config/routes.rb | 1 + test/controllers/events_controller_test.rb | 42 ++++++++++++++++++ 7 files changed, 128 insertions(+), 1 deletion(-) 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 fc6ecbb0f..3f1e36ca4 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -98,6 +98,17 @@ def new end: Time.zone.now.change(hour: 17)) end + # GET /events/timezone_suggestion?latitude=..&longitude=..&country=..&timezone=.. + # The time zone of the location entered in the event form, and a message if it differs from the selected one. + def timezone_suggestion + authorize Event, :new? + suggestion = TimezoneLookup.find(latitude: params[:latitude], longitude: params[:longitude], + country: params[:country]) + message = timezone_suggestion_message(suggestion, params[:timezone].presence) + + render json: { timezone: suggestion, message:, button: (t('events.timezone_suggestion.use', suggestion:) if message) } + end + # GET /events/1/clone def clone authorize @event @@ -228,6 +239,16 @@ def redirect private + def timezone_suggestion_message(suggestion, selected) + return nil if suggestion.nil? || (selected && TimezoneLookup.same_offsets?(selected, suggestion)) + + if selected + t('events.timezone_suggestion.mismatch', suggestion:, selected:) + else + t('events.timezone_suggestion.blank', suggestion:) + end + end + # Use callbacks to share common setup or constraints between actions. def set_event @event = Event.friendly.find(params[:id]) diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index 7dbb92a6b..d423deb8f 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -48,6 +48,11 @@ <%= f.input :timezone, as: :time_zone, field_lock: true, priority: priority_time_zones, input_html: { class: 'js-select2', title: t('events.hints.timezone') } %> + <%= f.input :duration, as: :string, input_html: { title: t('events.hints.duration') } %> diff --git a/config/locales/en.yml b/config/locales/en.yml index bfb450860..6428a3f03 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -635,6 +635,12 @@ en: 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/test/controllers/events_controller_test.rb b/test/controllers/events_controller_test.rb index cd83a8d93..32654330f 100644 --- a/test/controllers/events_controller_test.rb +++ b/test/controllers/events_controller_test.rb @@ -1772,4 +1772,46 @@ class EventsControllerTest < ActionController::TestCase 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