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/6] 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/6] 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/6] 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/6] 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/6] 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/6] 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