diff --git a/app/decorators/event_decorator.rb b/app/decorators/event_decorator.rb index 1456d5a269..9c42836cc2 100644 --- a/app/decorators/event_decorator.rb +++ b/app/decorators/event_decorator.rb @@ -39,14 +39,14 @@ def display_image end def date - start_date.strftime("%B %d, %Y") + start_date.in_time_zone(event_zone).strftime("%B %d, %Y") end # Weekday-prefixed date range (e.g. "Thu-Fri, Jan 1-2, 2026") that collapses the # year — and the month/weekday where possible — so nothing repeats unnecessarily. def date_range - s = start_date.in_time_zone(Time.zone) - e = (end_date || start_date).in_time_zone(Time.zone) + s = start_date.in_time_zone(event_zone) + e = (end_date || start_date).in_time_zone(event_zone) return s.strftime("%a, %b %-d, %Y") if s.to_date == e.to_date if s.year == e.year && s.month == e.month @@ -61,8 +61,8 @@ def date_range # Same collapsed range as `date_range` but without the weekday prefix # (e.g. "Sep 20-21, 2026") — for tighter contexts where the weekday is noise. def short_date_range - s = start_date.in_time_zone(Time.zone) - e = (end_date || start_date).in_time_zone(Time.zone) + s = start_date.in_time_zone(event_zone) + e = (end_date || start_date).in_time_zone(event_zone) return s.strftime("%b %-d, %Y") if s.to_date == e.to_date if s.year == e.year && s.month == e.month @@ -212,12 +212,14 @@ def videoconference_calendar_pending_note_html(portal_url, payment_pending: fals # the "both days" notes in the registration details panel. def multi_day? return false unless start_date && end_date - start_date.in_time_zone(Time.zone).to_date != end_date.in_time_zone(Time.zone).to_date + start_date.in_time_zone(event_zone).to_date != end_date.in_time_zone(event_zone).to_date end - def times(display_day: false, display_date: false, inline: false, styled: false) - s = start_date.in_time_zone(Time.zone) - e = (end_date || start_date).in_time_zone(Time.zone) + # Renders in the event's own zone by default; mailers pass `zone:` to show times + # in the recipient's zone instead. + def times(display_day: false, display_date: false, inline: false, styled: false, zone: event_zone) + s = start_date.in_time_zone(zone) + e = (end_date || start_date).in_time_zone(zone) tz_abbr = s.strftime("%Z") muted = styled ? "text-lg font-normal text-blue-400" : nil diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 99b790e99c..38e608307d 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -72,7 +72,7 @@ def default_reminder_message(days_until_event) # reminder page (admins can edit it). Mirrors the mailer's fallback subject; the # event date is event-level, resolved here in the app default time zone. def default_reminder_subject(event) - date_suffix = event.start_date.present? ? " – #{event.start_date.in_time_zone.strftime('%B %-d, %Y')}" : "" + date_suffix = event.start_date.present? ? " – #{event.start_date.in_time_zone(event.event_zone).strftime('%B %-d, %Y')}" : "" "AWBW Portal: Reminder: #{event.title}#{date_suffix}" end @@ -119,10 +119,10 @@ def form_header_uses_tokens?(form) # Weekday-prefixed date for the registration details panel, without the year # (the year lives in the page hero) — e.g. "Wednesday, August 12" or # "Thursday-Friday, July 23-24". Nil when the event has no start date. - def event_dates_detail_label(event) + def event_dates_detail_label(event, zone: event&.event_zone) return unless event&.start_date - s = event.start_date.in_time_zone(Time.zone) - e = (event.end_date || event.start_date).in_time_zone(Time.zone) + s = event.start_date.in_time_zone(zone) + e = (event.end_date || event.start_date).in_time_zone(zone) return s.strftime("%A, %B %-d") if s.to_date == e.to_date if s.year == e.year && s.month == e.month "#{s.strftime("%A")}-#{e.strftime("%A")}, #{s.strftime("%B %-d")}-#{e.strftime("%-d")}" @@ -135,8 +135,8 @@ def event_dates_detail_label(event) # event show page's date line, or nil when the event has no start date. def event_dates_label(event) return unless event&.start_date - s = event.start_date.in_time_zone(Time.zone) - e = (event.end_date || event.start_date).in_time_zone(Time.zone) + s = event.start_date.in_time_zone(event.event_zone) + e = (event.end_date || event.start_date).in_time_zone(event.event_zone) return s.strftime("%B %-d, %Y") if s.to_date == e.to_date if s.year == e.year && s.month == e.month "#{s.strftime("%B %-d")}-#{e.strftime("%-d")}, #{s.year}" @@ -149,10 +149,10 @@ def event_dates_label(event) # Event start-end time as plain text (e.g. "9 am - 4:30 pm PST"), mirroring the # event show page's time formatting (minutes hidden when :00), or nil with no start. - def event_times_label(event) + def event_times_label(event, zone: event&.event_zone) return unless event&.start_date - s = event.start_date.in_time_zone(Time.zone) - e = (event.end_date || event.start_date).in_time_zone(Time.zone) + s = event.start_date.in_time_zone(zone) + e = (event.end_date || event.start_date).in_time_zone(zone) format = ->(d) do t = d.strftime("%-l") t += ":#{d.strftime("%M")}" unless d.strftime("%M") == "00" @@ -197,7 +197,7 @@ def event_registration_close_label(event) def event_registration_close_date_label(event) close = event&.registration_close_date return unless close - close.in_time_zone(Time.zone).strftime("%B %-d") + close.in_time_zone(event.event_zone).strftime("%B %-d") end # Just the time portion of the registration close, prefixed with "at" and @@ -206,7 +206,7 @@ def event_registration_close_date_label(event) def event_registration_close_time_label(event) close = event&.registration_close_date return unless close - local = close.in_time_zone(Time.zone) + local = close.in_time_zone(event.event_zone) time = local.strftime("%-l") time += ":#{local.strftime("%M")}" unless local.strftime("%M") == "00" time += local.strftime("%P") @@ -218,7 +218,7 @@ def event_registration_close_time_label(event) # fall back to two days out at 9am. def event_registration_close_default(event) start = event&.start_date - base = start ? (start.in_time_zone(Time.zone) - 1.day).beginning_of_week(:monday) : 2.days.from_now + base = start ? (start.in_time_zone(event.event_zone) - 1.day).beginning_of_week(:monday) : 2.days.from_now base.change(hour: 9, min: 0) end diff --git a/app/models/event.rb b/app/models/event.rb index 7b2be39a22..7e07d282ea 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -53,6 +53,7 @@ class Event < ApplicationRecord # Validations validates_presence_of :title, :start_date, :end_date validates_inclusion_of :published, in: [ true, false ] + validate :time_zone_must_be_valid validates_numericality_of :cost_cents, greater_than_or_equal_to: 0, allow_nil: true validate :registration_form_required_when_publicly_registerable, on: :update validate :staff_members_are_unique, on: :update @@ -75,7 +76,7 @@ def self.remote_search(query) end def remote_search_label - label = start_date ? "#{title} (#{start_date.to_date.to_fs(:long)})" : title + label = start_date ? "#{title} (#{start_date.in_time_zone(event_zone).to_date.to_fs(:long)})" : title { id: id, label: label } end @@ -179,8 +180,8 @@ def registerable? def day_count return 1 if start_date.blank? - last_day = (end_date.presence || start_date).to_date - span = (last_day - start_date.to_date).to_i + 1 + last_day = (end_date.presence || start_date).in_time_zone(event_zone).to_date + span = (last_day - start_date.in_time_zone(event_zone).to_date).to_i + 1 span.clamp(1, 5) end @@ -190,7 +191,7 @@ def time_title # Like time_title but date only — no time or parens — for filter dropdowns. def date_title - start_date ? "#{start_date.to_date.iso8601} — #{name}" : name + start_date ? "#{start_date.in_time_zone(event_zone).to_date.iso8601} — #{name}" : name end def full_name @@ -198,7 +199,7 @@ def full_name end def start_text - start_date.strftime("%Y-%m-%d @ %I:%M %p") + start_date.in_time_zone(event_zone).strftime("%Y-%m-%d @ %I:%M %p") end def name @@ -212,33 +213,47 @@ def ce_hours_label registration_ticket_callouts.find_by(builtin_key: "ce_hours")&.title.presence || "CE hours" end - # Virtual attributes for date/time inputs (Firefox datetime-local compat) + # Default zone for events with no explicit time_zone — existing rows are left + # NULL (no backfill) and read as Pacific, and the form selector defaults here. + DEFAULT_TIME_ZONE = "Pacific Time (US & Canada)".freeze + + # The event's own canonical zone — the times entered and displayed are pinned to + # it, so an event no longer floats to whoever's viewing. A blank/unrecognized + # column falls back to the default (Pacific). + def event_zone + ActiveSupport::TimeZone[time_zone.presence || DEFAULT_TIME_ZONE] || + ActiveSupport::TimeZone[DEFAULT_TIME_ZONE] + end + + # Virtual attributes for date/time inputs (Firefox datetime-local compat). Read + # and write in the event's own zone so the form round-trips wall-clock times + # regardless of the editing admin's session zone. attr_writer :start_date_date, :start_date_time, :end_date_date, :end_date_time, :registration_close_date_date, :registration_close_date_time def start_date_date - @start_date_date || start_date&.strftime("%Y-%m-%d") + @start_date_date || start_date&.in_time_zone(event_zone)&.strftime("%Y-%m-%d") end def start_date_time - @start_date_time || start_date&.strftime("%H:%M") + @start_date_time || start_date&.in_time_zone(event_zone)&.strftime("%H:%M") end def end_date_date - @end_date_date || end_date&.strftime("%Y-%m-%d") + @end_date_date || end_date&.in_time_zone(event_zone)&.strftime("%Y-%m-%d") end def end_date_time - @end_date_time || end_date&.strftime("%H:%M") + @end_date_time || end_date&.in_time_zone(event_zone)&.strftime("%H:%M") end def registration_close_date_date - @registration_close_date_date || registration_close_date&.strftime("%Y-%m-%d") + @registration_close_date_date || registration_close_date&.in_time_zone(event_zone)&.strftime("%Y-%m-%d") end def registration_close_date_time - @registration_close_date_time || registration_close_date&.strftime("%H:%M") + @registration_close_date_time || registration_close_date&.in_time_zone(event_zone)&.strftime("%H:%M") end # Virtual attribute for cost in dollars (converts to/from cost_cents) @@ -315,9 +330,15 @@ def merge_date_time(field) def build_datetime(date_str, time_str) return nil if date_str.blank? && time_str.blank? - return Time.zone.parse(date_str) if date_str.present? && time_str.blank? - return Time.zone.parse("2000-01-01 #{time_str}") if date_str.blank? && time_str.present? - Time.zone.parse("#{date_str} #{time_str}") + return event_zone.parse(date_str) if date_str.present? && time_str.blank? + return event_zone.parse("2000-01-01 #{time_str}") if date_str.blank? && time_str.present? + event_zone.parse("#{date_str} #{time_str}") + end + + def time_zone_must_be_valid + return if time_zone.blank? || ActiveSupport::TimeZone[time_zone] + + errors.add(:time_zone, "is not a valid time zone") end def registration_form_required_when_publicly_registerable diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index a23354bc7a..2a7e8e350a 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -299,7 +299,7 @@ def self.search_by_params(params) end def name - "(#{ registrant&.full_name }) #{ event.start_date.strftime("%Y-%m-%d @ %I:%M %p") }: #{ event.title }" + "(#{ registrant&.full_name }) #{ event.start_date.in_time_zone(event.event_zone).strftime("%Y-%m-%d @ %I:%M %p") }: #{ event.title }" end # Email the communications box matches notifications against. Uniform accessor diff --git a/app/policies/event_policy.rb b/app/policies/event_policy.rb index 3d054f6264..dbb8927f6a 100644 --- a/app/policies/event_policy.rb +++ b/app/policies/event_policy.rb @@ -160,6 +160,7 @@ def google_analytics? :pre_date_text, :facilitator_training, :featured, + :time_zone, :start_date, :start_date_date, :start_date_time, :end_date, :end_date_date, :end_date_time, :registration_close_date, :registration_close_date_date, :registration_close_date_time, diff --git a/app/views/event_mailer/_event_details_card.html.erb b/app/views/event_mailer/_event_details_card.html.erb index 4d168e1203..01f6824651 100644 --- a/app/views/event_mailer/_event_details_card.html.erb +++ b/app/views/event_mailer/_event_details_card.html.erb @@ -13,18 +13,16 @@ <%= event.title %> - <% Time.use_zone(time_zone) do %> - <% if event_dates_detail_label(event.object).present? %> -
- <%= event_dates_detail_label(event.object) %> -
- <% end %> + <% if event_dates_detail_label(event.object, zone: time_zone).present? %> ++ <%= event_dates_detail_label(event.object, zone: time_zone) %> +
+ <% end %> - <% if event_times_label(event.object).present? %> -- <%= event_times_label(event.object) %> -
- <% end %> + <% if event_times_label(event.object, zone: time_zone).present? %> ++ <%= event_times_label(event.object, zone: time_zone) %> +
<% end %> <% if event.labelled_cost.present? %> diff --git a/app/views/event_mailer/event_registration_cancelled.html.erb b/app/views/event_mailer/event_registration_cancelled.html.erb index 81504622ee..b02033c91f 100644 --- a/app/views/event_mailer/event_registration_cancelled.html.erb +++ b/app/views/event_mailer/event_registration_cancelled.html.erb @@ -21,7 +21,7 @@- <% Time.use_zone(@time_zone) { %><%= @event.times(display_day: true, display_date: true) %><% } %> + <%= @event.times(display_day: true, display_date: true, zone: @time_zone) %>
<% if @event.location.present? %> diff --git a/app/views/event_mailer/event_registration_cancelled.text.erb b/app/views/event_mailer/event_registration_cancelled.text.erb index ac450c603e..84bce1cf94 100644 --- a/app/views/event_mailer/event_registration_cancelled.text.erb +++ b/app/views/event_mailer/event_registration_cancelled.text.erb @@ -6,7 +6,7 @@ Your registration for the following event has been cancelled: <% if @event.respond_to?(:pre_title) && @event.pre_title.present? %><%= @event.pre_title %> <% end %><%= @event.title %> -<% Time.use_zone(@time_zone) { %><%= @event.times(display_day: true, display_date: true) %><% } %> +<%= @event.times(display_day: true, display_date: true, zone: @time_zone) %> <% if @event.location.present? %> Location: <%= @event.location.name %> diff --git a/app/views/event_mailer/event_registration_confirmation.html.erb b/app/views/event_mailer/event_registration_confirmation.html.erb index 16dee070c4..19fbb097ed 100644 --- a/app/views/event_mailer/event_registration_confirmation.html.erb +++ b/app/views/event_mailer/event_registration_confirmation.html.erb @@ -21,7 +21,7 @@- <% Time.use_zone(@time_zone) { %><%= @event.times(display_day: true, display_date: true) %><% } %> + <%= @event.times(display_day: true, display_date: true, zone: @time_zone) %>
<% if @event.labelled_cost.present? %> diff --git a/app/views/event_mailer/event_registration_confirmation.text.erb b/app/views/event_mailer/event_registration_confirmation.text.erb index 0cdf4aab69..9de78789ed 100644 --- a/app/views/event_mailer/event_registration_confirmation.text.erb +++ b/app/views/event_mailer/event_registration_confirmation.text.erb @@ -6,7 +6,7 @@ This message confirms your registration for the following event: <% if @event.respond_to?(:pre_title) && @event.pre_title.present? %><%= @event.pre_title %> <% end %><%= @event.title %> -<% Time.use_zone(@time_zone) { %><%= @event.times(display_day: true, display_date: true) %><% } %> +<%= @event.times(display_day: true, display_date: true, zone: @time_zone) %> <% if @event.location.present? %> Location: <%= @event.location.name %> diff --git a/app/views/event_mailer/event_registration_reminder.text.erb b/app/views/event_mailer/event_registration_reminder.text.erb index 220570b8a0..5015fbd589 100644 --- a/app/views/event_mailer/event_registration_reminder.text.erb +++ b/app/views/event_mailer/event_registration_reminder.text.erb @@ -5,14 +5,12 @@ Event reminder Hello<%= @person.full_name %>, <% if @event.respond_to?(:pre_title) && @event.pre_title.present? %> <%= @event.pre_title %> <% end %><%= @event.title %> -<% Time.use_zone(@time_zone) do %> - <% if event_dates_detail_label(@event.object).present? %> - <%= event_dates_detail_label(@event.object) %> - <% end %> +<% if event_dates_detail_label(@event.object, zone: @time_zone).present? %> + <%= event_dates_detail_label(@event.object, zone: @time_zone) %> +<% end %> - <% if event_times_label(@event.object).present? %> - <%= event_times_label(@event.object) %> - <% end %> +<% if event_times_label(@event.object, zone: @time_zone).present? %> + <%= event_times_label(@event.object, zone: @time_zone) %> <% end %> <% if event_location_label(@event.object).present? %> Location: diff --git a/app/views/event_mailer/event_registration_reminder_fyi.text.erb b/app/views/event_mailer/event_registration_reminder_fyi.text.erb index 7a418c5527..eedec2d504 100644 --- a/app/views/event_mailer/event_registration_reminder_fyi.text.erb +++ b/app/views/event_mailer/event_registration_reminder_fyi.text.erb @@ -10,9 +10,9 @@ Reminder content sent to each registrant: <% if @custom_message.present? %><%= strip_tags(@custom_message).strip %> <% end %><%= @event.title %> -<% Time.use_zone(@time_zone) do %><% if event_dates_detail_label(@event.object).present? %><%= event_dates_detail_label(@event.object) %> -<% end %><% if event_times_label(@event.object).present? %><%= event_times_label(@event.object) %> -<% end %><% end %><% if @event.labelled_cost.present? %> +<% if event_dates_detail_label(@event.object, zone: @time_zone).present? %><%= event_dates_detail_label(@event.object, zone: @time_zone) %> +<% end %><% if event_times_label(@event.object, zone: @time_zone).present? %><%= event_times_label(@event.object, zone: @time_zone) %> +<% end %><% if @event.labelled_cost.present? %> <%= @event.labelled_cost %> <% end %><% if event_location_label(@event.object).present? %> Location: <%= event_location_label(@event.object) %> diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index 042b3c7ed2..b414eddc58 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -77,6 +77,17 @@Toggle which details appear on the event page.
Fields with no value will be ignored.