Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ The following people have contributed to the TeSS codebase:
* Milo Thurston
* Daan van Vugt <dvanvugt@ignitioncomputing.com>
* Martin Voigt <m.voigt@hzdr.de>
* Stian Soiland-Reyes <soiland-reyes@manchester.ac.uk>
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 4 additions & 4 deletions app/controllers/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions app/helpers/events_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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,
Expand Down
23 changes: 22 additions & 1 deletion app/models/event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions app/serializers/event_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions docs/timezones.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions lib/bioschemas/course_instance_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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? ||
Expand Down
4 changes: 2 additions & 2 deletions lib/bioschemas/event_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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? ||
Expand Down
5 changes: 5 additions & 0 deletions lib/bioschemas/generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions lib/ingestors/heptraining/gray_scott_ingestor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions lib/ingestors/ical_ingestor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions lib/ingestors/indico_ingestor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions lib/ingestors/ingestor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions public/api/definitions/tess.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions test/controllers/events_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
16 changes: 16 additions & 0 deletions test/helpers/events_helper_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
26 changes: 22 additions & 4 deletions test/integration/bioschemas_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions test/models/event_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
Loading
Loading