Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ca5a8d0
Move the time/timezone rendering into own partial, with optional time…
cwant Jun 27, 2025
5d927af
Create a session variable to hold a timezone override for views.
cwant Jun 27, 2025
2b3645a
Add user interface for controlling what timezone times are displayed in.
cwant Jun 27, 2025
037f454
Show timezone controls on pages that need it.
cwant Jun 27, 2025
230de2c
Translations of time, date, timezones for event display (via ffi-icu …
cwant Jun 27, 2025
26ac4d1
Show timezone controls on calendar view, but don't do anything (yet).
cwant Sep 9, 2025
c6eec5b
Let the user create/edit start/end times in the user interface in the…
cwant Sep 2, 2025
dca60cd
Some adjustments need moving form Explora to Elixir.
cwant Jun 17, 2026
af8b11e
Make timezone controls optional (default: turned on).
cwant Jun 17, 2026
8ac0b2d
Per advice from Copilot: ensure the event_time_data action doesn't ne…
cwant Jul 8, 2026
3058ae2
Per advice from Copilot: better logic (and permissions) in event_time…
cwant Jul 8, 2026
98f790e
Per advice from Copilot: safer timezone conversions for editing/saving.
cwant Jul 8, 2026
fbe4a57
Partial advice from Copilot: make the local time stuff more fault tol…
cwant Jul 9, 2026
31729be
Per advice from Copilot: smarter fragment caching.
cwant Jul 9, 2026
a7e1fb0
Per advice from Copilot: address some accessibitily issues.
cwant Jul 9, 2026
0295177
Per advice from from Copilot: fix typo in comment.
cwant Jul 9, 2026
f5809bb
Per advice from Copilot: don't hardcode an ajax URL.
cwant Jul 9, 2026
7c6ea50
Per advice from Copilot: test blank? rather than falsiness.
cwant Jul 9, 2026
f340ad0
As per advice from @kennethrioja: make timezone controls off by default.
cwant Jul 9, 2026
0081ce3
Fix an issue reported by @kennethrioja: don't show timezone controls …
cwant Jul 9, 2026
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 Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ gem 'country_select'
gem 'devise'
gem 'devise_invitable'
gem 'eventbrite_sdk'
gem 'ffi-icu'
gem 'font-awesome-sass', '~> 4.7.0' # Prefer V4 icon styles
gem 'friendly_id'
gem 'geocoder'
Expand Down
3 changes: 3 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,8 @@ GEM
faraday-net_http (3.4.2)
net-http (~> 0.5)
ffi (1.15.5)
ffi-icu (0.5.3)
ffi (~> 1.0, >= 1.0.9)
font-awesome-sass (4.7.0)
sass (>= 3.2)
friendly_id (5.5.0)
Expand Down Expand Up @@ -857,6 +859,7 @@ DEPENDENCIES
devise
devise_invitable
eventbrite_sdk
ffi-icu
font-awesome-sass (~> 4.7.0)
friendly_id
geocoder
Expand Down
43 changes: 43 additions & 0 deletions app/assets/javascripts/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,46 @@ var Events = {
$('.address_content').show()
}
}
},

updateDateTimes: function (render_controls = false) {
// Assemble the data parameters for the query string
var event_ids = $.map($('.time-with-zone'), function(div) {
return $(div).data('event-id')
})
var timezone = $('input[type=radio][name=timezone-choice]:checked').val();
var data = { event_ids: event_ids, tz: timezone}
if (render_controls) {
data['browser_timezone'] = Intl.DateTimeFormat().resolvedOptions().timeZone;
}

// Server renders the various DOM ids that need updating
$.ajax({
url: $('#timezone-controls').data('event-time-data-url'),
data: data,
Comment thread
cwant marked this conversation as resolved.
context: document.body
}).done(function(elements) {
// Replace content of server-rendered ids with new content
elements.forEach(function(element) {
$(element['id']).replaceWith(element['html'])
})

if (render_controls) {
// This is just run on initial page load, set up controls
// (controls aren't rendered on page initially served)
$('#timezone-controls .close-timezone-controls').click(function() {
$('#timezone-select').collapse('hide')
$('#timezone-controls .show-timezone-controls').show();
});
$('#timezone-controls .show-timezone-controls').click(function() {
$(this).hide();
});
$('input[type=radio][name=timezone-choice]').change(function() {
Events.updateDateTimes();
$('.timezone-display-value').toggleClass('unselected')
})
}
});
}
}

Expand All @@ -40,4 +80,7 @@ $(document).on('change', '.event_cost_basis', function () {
$(document).on('ready turbolinks:load', function () {
Events.switchCostFields();
Events.switchAddressFields();
if ($('.time-with-zone').length) {
Events.updateDateTimes(true);
}
});
10 changes: 10 additions & 0 deletions app/assets/stylesheets/application.scss
Original file line number Diff line number Diff line change
Expand Up @@ -1121,3 +1121,13 @@ td.day .calendar-text {
.tag-topic, .tag-operation {
text-decoration: none;
}

#timezone-controls {
overflow: auto;
// font-size: 0.7em;
label { font-weight: inherit; }
fieldset legend { font-size: inherit; }
#timezone-display .unselected { display: none; }
.center-block { width: 80%; }
.close-timezone-controls { float: right; }
}
21 changes: 19 additions & 2 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,21 @@ class ApplicationController < ActionController::Base
include PublicActivity::StoreController

before_action :configure_permitted_parameters, if: :devise_controller?
before_action :timezone_from_params_or_session
Comment thread
cwant marked this conversation as resolved.

# Prevent CSRF attacks by raising an exception.
# For APIs, you may want to use :null_session instead.
protect_from_forgery with: :exception

# Should allow token authentication for API calls
acts_as_token_authentication_handler_for User, except: [:index, :show, :embed, :calendar, :check_exists, :handle_error, :count,
:redirect] #only: [:new, :create, :edit, :update, :destroy]
:redirect, :event_time_data]
#only: [:new, :create, :edit, :update, :destroy]

# User auth should be required in the web interface as well; it's here rather than in routes so that it
# doesn't override the token auth, above.
before_action :authenticate_user!, except: [:index, :show, :embed, :calendar, :check_exists, :handle_error, :count, :redirect]
before_action :authenticate_user!, except: [:index, :show, :embed, :calendar, :check_exists,
:handle_error, :count, :redirect, :event_time_data]
before_action :set_current_space
before_action :set_current_user

Expand Down Expand Up @@ -159,4 +162,18 @@ def configure_permitted_parameters
def allow_embedding
response.headers.delete 'X-Frame-Options'
end

def timezone_from_params_or_session
# TODO? have a list of acceptable time zones, maybe via config?
tz = params[:tz] || session['tz']

session['tz'] = if (tz.blank? || tz == 'reset')
nil
elsif ActiveSupport::TimeZone[tz].present?
tz
end

true
end

end
58 changes: 57 additions & 1 deletion app/controllers/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ class EventsController < ApplicationController
include FieldLockEnforcement
include TopicCuration

MAX_EVENT_TIMEZONE_IDS = 200

# GET /events
# GET /events.json
def index
Expand Down Expand Up @@ -108,6 +110,7 @@ def clone
# GET /events/1/edit
def edit
authorize @event
shift_times_to_timezone_for_editing
end

# GET /events/1/report
Expand Down Expand Up @@ -155,6 +158,8 @@ def check_exists
def create
authorize Event
@event = Event.new(event_params)
# TODO: should time only shift for timezone if format is HTML?
shift_times_to_utc_for_saving
@event.user = current_user
@event.space = current_space

Expand All @@ -174,8 +179,11 @@ def create
# PATCH/PUT /events/1.json
def update
authorize @event
@event.assign_attributes(event_params)
# TODO: should time only shift for timezone if format is HTML?
shift_times_to_utc_for_saving
respond_to do |format|
if @event.update(event_params)
if @event.save
@event.create_activity(:update, owner: current_user) if @event.log_update_activity?
format.html { redirect_to @event, notice: 'Event was successfully updated.' }
format.json { render :show, status: :ok, location: @event }
Expand Down Expand Up @@ -226,13 +234,61 @@ def redirect
redirect_to @event.url, allow_other_host: true
end

def event_time_data
# Serve JSON of some DOM ids that need updating, with rendered HTML content
respond_to do |format|
event_ids = params[:event_ids]
timezone = session[:tz]
browser_timezone = params[:browser_timezone]

# Render times for the following event ids
events = Event.where(id: Array(event_ids).uniq.first(MAX_EVENT_TIMEZONE_IDS))
out = events.map do |event|
next unless policy(event).show?

html = render_to_string partial: 'events/event_time_div',
formats: [:html],
locals: { event: event, timezone: timezone }

{ id: "#event-time-#{event.id}", html: html}
end.compact
if browser_timezone
# Render timezone controls
control_html = render_to_string partial: 'events/event_timezone_control',
formats: [:html],
locals: { browser_timezone: browser_timezone,
timezone: browser_timezone }
out << { id: '#timezone-controls', html: control_html }
end
format.json { render json: out}
end
end

private

# Use callbacks to share common setup or constraints between actions.
def set_event
@event = Event.friendly.find(params[:id])
end

def shift_times_to_timezone_for_editing
tz = @event&.timezone.to_s
return if tz.blank? || ActiveSupport::TimeZone[tz].blank?

@event.start = @event.start&.in_time_zone(tz)
@event.end = @event.end&.in_time_zone(tz)
end

def shift_times_to_utc_for_saving
return unless request.format.html?

tz = @event&.timezone.to_s
return if tz.blank? || ActiveSupport::TimeZone[tz].blank?

@event.start = @event.start&.change(zone: tz)
@event.end = @event.end&.change(zone: tz)
end

# Never trust parameters from the scary internet, only allow the white list through.
def event_params
params.require(:event).permit(:external_id, :title, :subtitle, :url, :organizer, :last_scraped, :scraper_record,
Expand Down
28 changes: 24 additions & 4 deletions app/helpers/events_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -148,19 +148,39 @@ def neatly_printed_date_range(start, finish = nil)
end

if finish.blank? || differing.empty?
out = start.strftime(DATE_STRF)
out = l(start, format: DATE_STRF)
# Don't show time component if they are set to midnight since that is the default if no time specified.
# Revisit this decision if any events start occurring at midnight (timezone issue?)!

show_time = (start.hour != 0 || start.min != 0) || (finish.present? && (finish.hour != 0 || finish.min != 0))
if show_time
out << " @ #{start.strftime(TIME_STRF)}"
out << " - #{finish.strftime(TIME_STRF)}" if finish && (finish.hour != start.hour || finish.min != start.min)
date_time_separator = if (I18n.locale.to_s == 'fr')
', '
else
' @ '
end
out << "#{date_time_separator}#{l(start, format: TIME_STRF)}"
out << " - #{l(finish, format: TIME_STRF)}" if finish && (finish.hour != start.hour || finish.min != start.min)
end
out
elsif differing.any?
"#{start.strftime(differing.join(' '))} - #{finish.strftime(DATE_STRF)}"
"#{l(start, format: differing.join(' '))} - #{l(finish, format: DATE_STRF)}"
end
end
end

def normalized_icu_timezone(name)
# Get more standardized name from TZinfo...
tz_name = ActiveSupport::TimeZone[name]&.tzinfo&.name

return nil unless tz_name

# The ICU library we use wants underscores not spaces ...
tz_name = tz_name.gsub(' ', '_')
formatter = ICU::TimeFormatting.create(locale: I18n.locale.to_s,
zone: tz_name,
skeleton: '%zzzz')
formatter.format(Time.now)
end

end
12 changes: 8 additions & 4 deletions app/models/event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -186,11 +186,11 @@ def end_utc
end

def start_local
set_to_local start
set_to_local(start)
end
Comment thread
cwant marked this conversation as resolved.

def end_local
set_to_local self.end
set_to_local(self.end)
end
Comment thread
cwant marked this conversation as resolved.

def started?
Expand Down Expand Up @@ -522,8 +522,12 @@ def convert_local_to_utc(datetime)
end

def set_to_local(datetime)
datetime.asctime.in_time_zone(timezone)
rescue StandardError
# Fallback to UTC when no timezone
return datetime&.in_time_zone unless timezone

time = datetime&.in_time_zone(timezone)
return time if time

datetime
end

Expand Down
4 changes: 4 additions & 0 deletions app/views/common/_associated_events.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,9 @@
<%= link_to "View all #{name.pluralize(shown_count)} & filter", inc_expired_link, class: 'btn btn-xs btn-default' %>
<% end %>

<% if TeSS::Config.feature.fetch('timezone_controls') && total_count > 0 %>
<%= render partial: 'events/event_timezone_control' %>
<% end %>

<%= render partial: 'common/masonry_grid', locals: { objects: resources } %>
</div>
6 changes: 5 additions & 1 deletion app/views/events/_event.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@
<%= elixir_node_icon %>
<% end -%>

<p><%= neatly_printed_date_range(event.start, event.end) %></p>
<% if TeSS::Config.feature.fetch('timezone_controls') %>
<%= render partial: 'events/event_time_div', locals: {event: event} %>
<% else %>
<p><%= neatly_printed_date_range(event.start, event.end) %></p>
<% end %>

<% if event.onsite? %>
<% location = [event.city, event.country].reject { |field_value| field_value.blank? }.join(", ") %>
Expand Down
16 changes: 16 additions & 0 deletions app/views/events/_event_time_div.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<% # Fake the timezone if passed as an argument
if local_assigns[:timezone]
saved_timezone = event.timezone
event.timezone = timezone
end %>
<div id="event-time-<%= event.id %>" data-event-id="<%= event.id %>" class="time-with-zone">
<p class="date no-spacing">
<strong class="text-primary"><%= t('events.date') %>: </strong>
<%= neatly_printed_date_range(event.start_local, event.end_local) %>
<% if event.timezone.blank? %>
<%= t('events.no_timezone_provided_gmt_assumed') %>
<% end %>
</p>
<%= display_attribute(event, :timezone) {|v| normalized_icu_timezone(v) } %>
</div>
<% event.timezone = saved_timezone if local_assigns[:timezone] %>
Loading
Loading