Skip to content
Closed
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>
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 2 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -949,6 +950,7 @@ DEPENDENCIES
web-console
webmock
whenever
wheretz
will_paginate

BUNDLED WITH
Expand Down
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.
51 changes: 51 additions & 0 deletions app/assets/javascripts/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
}
}

Expand All @@ -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();
});
3 changes: 2 additions & 1 deletion app/assets/javascripts/map_search.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
Expand Down
29 changes: 25 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,19 @@ def preview
# GET /events/new
def new
authorize Event
@event = Event.new(start: DateTime.now.change(hour: 9),
end: DateTime.now.change(hour: 17))
@event = Event.new(start: Time.zone.now.change(hour: 9),
end: Time.zone.now.change(hour: 17))
end

# GET /events/timezone_suggestion?latitude=..&longitude=..&country=..&timezone=..
# 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
Expand Down Expand Up @@ -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])
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
55 changes: 52 additions & 3 deletions app/models/event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 date-only events with default times: their floating local times show on the right day anywhere
before_save :clear_inferred_timezone, if: -> { timezone_inferred? && default_times? }
before_save :timezone_from_location, if: -> { timezone.blank? && !default_times? }
after_save :enqueue_geocoding_worker, if: :address_changed?

if TeSS::Config.solr_enabled
Expand Down Expand Up @@ -175,6 +179,8 @@ class Event < ApplicationRecord

COUNTRY_SYNONYMS = JSON.parse(File.read(File.join(Rails.root, 'config', 'data', 'country_synonyms.json')))

# Microseconds added to default times (see set_default_times)
DEFAULT_TIME_MARKER = 1
NOMINATIM_DELAY = 1.minute
NOMINATIM_MAX_ATTEMPTS = 3

Expand All @@ -194,6 +200,28 @@ 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

# Whether the start time is the default (09:00) that set_default_times gives events with only a date, rather than
# a time given by the user or the source
def default_times?
start&.usec == DEFAULT_TIME_MARKER
end

# The event's time zone, or nil if it has none or it is not recognised
def known_time_zone
ActiveSupport::TimeZone[timezone] if timezone.present?
end

def end_local
set_to_local self.end
end
Expand Down Expand Up @@ -259,9 +287,14 @@ def to_ical_event
if all_day?
ical_event.dtstart = Icalendar::Values::Date.new(start, tzid: 'UTC') unless start.blank?
ical_event.dtend = Icalendar::Values::Date.new(self.end.tomorrow, tzid: 'UTC') unless self.end.blank?
else
elsif known_time_zone
ical_event.dtstart = Icalendar::Values::DateTime.new(start_utc, tzid: 'UTC') unless start.blank?
ical_event.dtend = Icalendar::Values::DateTime.new(end_utc, tzid: 'UTC') unless self.end.blank?
else
# Time zone unknown: floating (local) times, e.g. DTSTART:20261105T090000, which calendars show at the same
# clock time and day in their own time zone, assuming people are near the event, rather than as UTC
ical_event.dtstart = Icalendar::Values::DateTime.new(start.strftime('%Y%m%dT%H%M%S'))
ical_event.dtend = Icalendar::Values::DateTime.new(self.end.strftime('%Y%m%dT%H%M%S'))
end

end
Expand Down Expand Up @@ -289,10 +322,12 @@ def all_day?
def set_default_times
return unless start

self.start = start + 9.hours if start.hour == 0 # hour set to 0 if not otherwise defined...
# Hour set to 0 if not otherwise defined, e.g. only a date was given. The default times are marked with 1
# microsecond (09:00:00.000001), which is not shown, so that they can be told apart (see default_times?)
self.start = (start + 9.hours).change(usec: DEFAULT_TIME_MARKER) if start.hour.zero?

if self.end
self.end = self.end + 17.hours if self.end.hour == 0 # hour set to 0 if not otherwise defined...
self.end = (self.end + 17.hours).change(usec: DEFAULT_TIME_MARKER) if self.end.hour.zero?
elsif online?
self.end = start + 1.hour
else
Expand Down Expand Up @@ -385,6 +420,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 the default 09:00-17:00 times (see default_times?), as combining two
# guesses would pin them to a time and day that may not apply.
def timezone_from_location
self.timezone = TimezoneLookup.find(latitude:, longitude:, country:)
self.timezone_inferred = timezone.present?
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
Expand Down
11 changes: 10 additions & 1 deletion app/serializers/event_serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ class EventSerializer < ApplicationSerializer

:keywords, :event_types, :scientific_topics, :operations, :fields, :external_resources,

:start, :end, :duration, :timezone,
:start, :end, :duration, :timezone, :timezone_inferred,

:organizer, :sponsors, :contact, :host_institutions,

Expand All @@ -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
5 changes: 5 additions & 0 deletions app/views/events/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@
<!-- Field: Timezone -->
<%= f.input :timezone, as: :time_zone, field_lock: true, priority: priority_time_zones,
input_html: { class: 'js-select2', title: t('events.hints.timezone') } %>
<div class="alert alert-warning" id="timezone-suggestion" role="status" style="display: none;"
data-url="<%= timezone_suggestion_events_path %>">
<span data-role="timezone-suggestion-message"></span>
<button type="button" class="btn btn-default btn-xs" data-role="timezone-suggestion-use"></button>
</div>

<!-- Field: Duration -->
<%= f.input :duration, as: :string, input_html: { title: t('events.hints.duration') } %>
Expand Down
2 changes: 1 addition & 1 deletion app/views/events/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
<strong class="text-primary"> Date: </strong>
<%= neatly_printed_date_range(@event.start, @event.end) %>
</p>
<%= 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) } %>
<!-- Field: description -->
Expand Down
7 changes: 7 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,13 @@ en:
controller: "The data controller for %{site_name} is The University of Manchester."
post: "<strong>The TeSS Team</strong><br/>Room 2.86 c/o School of Computer Science,<br/>Kilburn Building,<br/>Oxford Road,<br/>Manchester,<br/>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: >
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
collection do
get :count
get :calendar, format: %i[js html]
get :timezone_suggestion
post :preview
end
member do
Expand Down
5 changes: 5 additions & 0 deletions db/migrate/20261022230000_add_timezone_inferred_to_events.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddTimezoneInferredToEvents < ActiveRecord::Migration[8.1]
def change
add_column :events, :timezone_inferred, :boolean, default: false, null: false
end
end
3 changes: 2 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -228,6 +228,7 @@
t.string "target_audience", default: [], array: true
t.text "tech_requirements"
t.string "timezone"
t.boolean "timezone_inferred", default: false, null: false
t.string "title"
t.integer "trainer_count"
t.datetime "updated_at", null: false
Expand Down
5 changes: 5 additions & 0 deletions docs/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,8 @@ Precompile the assets, necessary if any CSS/JS/images are changed after building
Reindex Solr:

docker compose -f docker-compose-prod.yml exec app bundle exec rake tess:reindex

Set the time zone of events that have none from their location (coordinates or country), e.g. after upgrading.
New and edited events get one automatically; these are shown as "(inferred)":

docker compose -f docker-compose-prod.yml exec app bundle exec rake tess:infer_event_timezones
Loading
Loading