diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index abee164b4..1f11a00c6 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -17,3 +17,4 @@ The following people have contributed to the TeSS codebase: * Milo Thurston * Daan van Vugt * Martin Voigt +* Stian Soiland-Reyes diff --git a/README.md b/README.md index ee744a7b4..9668a5fc1 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,10 @@ See [here](docs/customization.md) for an overview of how you can customize your See [here](docs/api.md) for details on programmatic access to TeSS via its API. +## Event times and time zones + +See [here](docs/timezones.md) for how event times and time zones are stored, and what ingestors need to do. + ## Multi-space TeSS See [here](docs/spaces.md) for details on configuring a multi-space TeSS. diff --git a/app/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/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 4a72a3a08..14e9595f4 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -13,8 +13,11 @@ def google_calendar_export_url(event) dates = if event.all_day? # Need to add 1 day for all day events apparently "#{event.start.strftime('%Y%m%d')}/#{event.end.tomorrow.strftime('%Y%m%d')}" - else + elsif event.known_time_zone "#{event.start_utc.strftime('%Y%m%dT%H%M00Z')}/#{event.end_utc.strftime('%Y%m%dT%H%M00Z')}" + else + # Time zone unknown: local times, which Google Calendar takes in the user's time zone, rather than UTC + "#{event.start.strftime('%Y%m%dT%H%M00')}/#{event.end.strftime('%Y%m%dT%H%M00')}" end location = if event.online? @@ -26,7 +29,7 @@ def google_calendar_export_url(event) event_params = { text: event.title, dates:, - ctz: event.timezone, + ctz: event.known_time_zone&.tzinfo&.identifier, # IANA name, e.g. Australia/Perth rather than Perth details: "#{event_url(event)}", location:, sf: true, diff --git a/app/models/event.rb b/app/models/event.rb index 030b25f10..b21893c2c 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 @@ -259,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/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/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. diff --git a/lib/bioschemas/course_instance_generator.rb b/lib/bioschemas/course_instance_generator.rb index aad08b67e..5e34817c9 100644 --- a/lib/bioschemas/course_instance_generator.rb +++ b/lib/bioschemas/course_instance_generator.rb @@ -8,8 +8,8 @@ def self.bioschemas_profile 'https://bioschemas.org/profiles/CourseInstance/1.0-RELEASE' end - property :startDate, :start - property :endDate, :end + property :startDate, -> (event) { local_time(event, event.start) } + property :endDate, -> (event) { local_time(event, event.end) } property :organizer, -> (event) { { '@type' => 'Organization', name: event.organizer } if event.organizer.present? } property :location, -> (event) { address(event) }, condition: -> (event) { event.venue.present? || event.city.present? || event.county.present? || diff --git a/lib/bioschemas/event_generator.rb b/lib/bioschemas/event_generator.rb index 3722480a7..892bc935d 100644 --- a/lib/bioschemas/event_generator.rb +++ b/lib/bioschemas/event_generator.rb @@ -13,8 +13,8 @@ def self.bioschemas_profile property :url, :url property :description, :description property :keywords, :keywords - property :startDate, :start - property :endDate, :end + property :startDate, -> (event) { local_time(event, event.start) } + property :endDate, -> (event) { local_time(event, event.end) } property :organizer, -> (event) { { '@type' => 'Organization', name: event.organizer } if event.organizer.present? } property :location, -> (event) { address(event) }, condition: -> (event) { event.venue.present? || event.city.present? || event.county.present? || diff --git a/lib/bioschemas/generator.rb b/lib/bioschemas/generator.rb index 046bba106..a5d743743 100644 --- a/lib/bioschemas/generator.rb +++ b/lib/bioschemas/generator.rb @@ -79,6 +79,11 @@ def self.term(term) } end + # An event's start or end time in ISO 8601, with the offset of the event's time zone (see Event#local_iso8601) + def self.local_time(event, time) + event.local_iso8601(time) + end + def self.person(person) p = { "@type" => "Person", diff --git a/lib/ingestors/heptraining/gray_scott_ingestor.rb b/lib/ingestors/heptraining/gray_scott_ingestor.rb index e4525b3fe..ebc2f532c 100644 --- a/lib/ingestors/heptraining/gray_scott_ingestor.rb +++ b/lib/ingestors/heptraining/gray_scott_ingestor.rb @@ -41,10 +41,10 @@ def process_calevent(calevent, url) html_description = html.css('.paragraphStyle').text.to_s.strip event.description = html_description.empty? ? calevent.description.to_s : html_description - event.end = calevent.dtend&.to_time&.utc + event.end = ical_local_time(calevent.dtend) unless calevent.dtstart.nil? dtstart = calevent.dtstart - event.start = dtstart&.to_time&.utc + event.start = ical_local_time(dtstart) tzid = dtstart.ical_params['tzid'] event.timezone = tzid.first.to_s if !tzid.nil? && tzid.size.positive? end diff --git a/lib/ingestors/ical_ingestor.rb b/lib/ingestors/ical_ingestor.rb index e31ef95de..865ef5390 100644 --- a/lib/ingestors/ical_ingestor.rb +++ b/lib/ingestors/ical_ingestor.rb @@ -51,10 +51,10 @@ def process_event(calevent) event.title = calevent.summary&.to_s event.description = process_description(calevent.description) - event.end = calevent.dtend&.to_time + event.end = ical_local_time(calevent.dtend) unless calevent.dtstart.nil? dtstart = calevent.dtstart - event.start = dtstart&.to_time + event.start = ical_local_time(dtstart) tzid = dtstart.ical_params['tzid'] event.timezone = tzid.first.to_s if tzid.present? end diff --git a/lib/ingestors/indico_ingestor.rb b/lib/ingestors/indico_ingestor.rb index 66fa4fcaa..749678607 100644 --- a/lib/ingestors/indico_ingestor.rb +++ b/lib/ingestors/indico_ingestor.rb @@ -110,8 +110,8 @@ def assign_basic_info(event, calevent) # Assigns to event: start, end, timezone. def assign_time_info(event, calevent) - event.start = calevent.dtstart&.to_time unless calevent.dtstart.nil? - event.end = calevent.dtend&.to_time unless calevent.dtend.nil? + event.start = ical_local_time(calevent.dtstart) + event.end = ical_local_time(calevent.dtend) event.timezone = get_tzid(calevent.dtstart) end diff --git a/lib/ingestors/ingestor.rb b/lib/ingestors/ingestor.rb index 291412905..ac755e245 100644 --- a/lib/ingestors/ingestor.rb +++ b/lib/ingestors/ingestor.rb @@ -64,6 +64,16 @@ def stats_summary(type) summary end + # The local (wall clock) time of an iCalendar date or date-time, e.g. 09:45 for + # DTSTART;TZID=Australia/Perth:20220211T094500, as TeSS stores event times as local time, with the time zone + # in the event's +timezone+. A date (all-day event) gives midnight, whatever the server's time zone. + def ical_local_time(value) + return nil if value.nil? + + time = value.respond_to?(:hour) ? [value.hour, value.min, value.sec] : [] + Time.utc(value.year, value.month, value.day, *time) + end + def open_url(url, raise: false, token: nil) options = { redirect: false, # We're doing redirects manually below, since open-uri can't handle http -> https redirection diff --git a/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..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 @@ -1719,4 +1731,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/helpers/events_helper_test.rb b/test/helpers/events_helper_test.rb index 5f3d8d4c0..aa9f9bb8a 100644 --- a/test/helpers/events_helper_test.rb +++ b/test/helpers/events_helper_test.rb @@ -55,4 +55,20 @@ class EventsHelperTest < ActionView::TestCase assert_equal 'No date given', neatly_printed_date_range(nil, '') assert_equal 'No start date', neatly_printed_date_range(nil, DateTime.new(2024, 5, 16, 17)) end + + test 'google calendar export in UTC with the IANA time zone when the time zone is known' do + event = events(:one) + event.assign_attributes(timezone: 'Perth', start: Time.utc(2022, 2, 11, 9, 45), end: Time.utc(2022, 2, 11, 12, 50)) + params = Rack::Utils.parse_query(URI.parse(google_calendar_export_url(event)).query) + assert_equal '20220211T014500Z/20220211T045000Z', params['dates'] + assert_equal 'Australia/Perth', params['ctz'] + end + + test 'google calendar export in local time when the time zone is unknown' do + event = events(:one) + event.assign_attributes(timezone: nil, start: Time.utc(2026, 11, 5, 9), end: Time.utc(2026, 11, 5, 17)) + params = Rack::Utils.parse_query(URI.parse(google_calendar_export_url(event)).query) + assert_equal '20261105T090000/20261105T170000', params['dates'] + assert_nil params['ctz'] + end end diff --git a/test/integration/bioschemas_test.rb b/test/integration/bioschemas_test.rb index 9b7a52c2c..2948e0ea6 100644 --- a/test/integration/bioschemas_test.rb +++ b/test/integration/bioschemas_test.rb @@ -33,8 +33,8 @@ class BioschemasTest < ActionDispatch::IntegrationTest assert_equal ['an event to test calendar exports'], props['alternateName'] assert_equal ['http://microsoft.com'], props['url'] assert_equal ['Keyword'], props['keywords'] - assert_equal ['2021-09-20 23:15:00 UTC'], props['startDate'] - assert_equal ['2021-09-21 01:00:00 UTC'], props['endDate'] + assert_equal ['2021-09-20T23:15:00+10:00'], props['startDate'] + assert_equal ['2021-09-21T01:00:00+10:00'], props['endDate'] # Address q = RDF::Query.new do @@ -125,8 +125,8 @@ class BioschemasTest < ActionDispatch::IntegrationTest assert_equal ['Learn stuff', 'Get a job', 'Get paid'].sort, course_props['teaches'].sort, 'Should split the markdown into an array' assert_equal ['de'].sort, course_props['inLanguage'] - assert_equal ['2015-08-23 10:16:33 UTC'], course_instance_props['startDate'] - assert_equal ['2015-08-24 18:07:46 UTC'], course_instance_props['endDate'] + assert_equal ['2015-08-23T10:16:33Z'], course_instance_props['startDate'] + assert_equal ['2015-08-24T18:07:46Z'], course_instance_props['endDate'] # Audience q = RDF::Query.new do @@ -290,6 +290,24 @@ class BioschemasTest < ActionDispatch::IntegrationTest assert_equal ["The meaning of life\n * item1 \n * item2\n"], course_props['teaches'] end + test 'event dates in ISO 8601 with the offset of the event timezone on that date' do + event = events(:training_event) + event.update_columns(timezone: 'Eastern Time (US & Canada)', start: Time.utc(2021, 7, 1, 9), end: Time.utc(2021, 12, 1, 17)) + + props = Bioschemas::EventGenerator.new(event).generate + assert_equal '2021-07-01T09:00:00-04:00', props[:startDate], 'Should use daylight saving time in summer' + assert_equal '2021-12-01T17:00:00-05:00', props[:endDate] + end + + test 'event dates without offset when timezone is unknown' do + event = events(:training_event) + event.update_columns(timezone: nil, start: Time.utc(2021, 7, 1, 9), end: Time.utc(2021, 7, 1, 17)) + + props = Bioschemas::EventGenerator.new(event).generate + assert_equal '2021-07-01T09:00:00', props[:startDate] + assert_equal '2021-07-01T17:00:00', props[:endDate] + end + test 'Bioschemas on event index page' do event = events(:training_event) course = events(:course_event) diff --git a/test/models/event_test.rb b/test/models/event_test.rb index 55ae09150..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? 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