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 @@
<%# LEFT: Date/time, cost + pre-date text, videoconference + location %>
+
+ + <%= f.time_zone_select :time_zone, + ActiveSupport::TimeZone.us_zones, + { default: Event::DEFAULT_TIME_ZONE }, + class: "min-w-0 flex-1 rounded border-gray-300 shadow-sm px-2 py-1.5 text-sm focus:ring-blue-500 focus:border-blue-500" %> +
+

Start

@@ -225,7 +236,7 @@

Toggle which details appear on the event page.
Fields with no value will be ignored.

- <% tz = Time.zone %> + <% tz = @event.event_zone %> <% tz_abbr = @event.start_date.present? ? @event.start_date.in_time_zone(tz).strftime("%Z") : Time.current.strftime("%Z") %> <%= f.input :autoshow_pre_date_text, as: :boolean, label: ("Pre-date text" + (@event.pre_date_text.present? ? " #{h @event.pre_date_text}" : "")).html_safe %> diff --git a/app/views/events/show.html.erb b/app/views/events/show.html.erb index 390644248a..2dcbda8b42 100644 --- a/app/views/events/show.html.erb +++ b/app/views/events/show.html.erb @@ -94,8 +94,8 @@ <% unless current_user && @event.actively_registered?(current_user.person) %> <% if @event.autoshow_registration_close && @event.registration_close_date %>
- <% tz_abbr = @event.start_date.in_time_zone(Time.zone).strftime("%Z") %> - Registration closes <%= @event.registration_close_date.in_time_zone(Time.zone).strftime("%B %-d, %Y %l:%M %P") %> <%= tz_abbr %> + <% tz_abbr = @event.start_date.in_time_zone(@event.event_zone).strftime("%Z") %> + Registration closes <%= @event.registration_close_date.in_time_zone(@event.event_zone).strftime("%B %-d, %Y %l:%M %P") %> <%= tz_abbr %>
<% end %> <% end %> diff --git a/db/migrate/20260804000708_add_time_zone_to_events.rb b/db/migrate/20260804000708_add_time_zone_to_events.rb new file mode 100644 index 0000000000..2da3c21590 --- /dev/null +++ b/db/migrate/20260804000708_add_time_zone_to_events.rb @@ -0,0 +1,12 @@ +class AddTimeZoneToEvents < ActiveRecord::Migration[8.1] + # Nullable with no default: existing rows stay NULL (no backfill), and the app + # treats a blank zone as Pacific via Event#event_zone. New events entered through + # the form carry an explicit zone (the selector defaults to Pacific). + def up + add_column :events, :time_zone, :string + end + + def down + remove_column :events, :time_zone, if_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 7294af8199..622b603bd1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -563,6 +563,7 @@ t.text "short_description" t.boolean "signed_in_one_click_enabled", default: false, null: false t.datetime "start_date", precision: nil + t.string "time_zone" t.string "title" t.datetime "updated_at", null: false t.string "videoconference_label", default: "Virtual event" diff --git a/spec/factories/events.rb b/spec/factories/events.rb index 24ee680315..fb38d50fe1 100644 --- a/spec/factories/events.rb +++ b/spec/factories/events.rb @@ -3,6 +3,10 @@ association :created_by, factory: :user title { "sample title" } description { "sample description" } + # Default to the ambient zone so factory-built times (`Time.zone.local(...)`) + # display in the same zone they were constructed in. Production events default + # to Pacific via the DB column; override explicitly to exercise zone behavior. + time_zone { Time.zone.name } start_date { 12.days.from_now } end_date { 14.days.from_now } registration_close_date { 13.days.from_now } diff --git a/spec/mailers/event_mailer_spec.rb b/spec/mailers/event_mailer_spec.rb index a4291b031e..f33fc36469 100644 --- a/spec/mailers/event_mailer_spec.rb +++ b/spec/mailers/event_mailer_spec.rb @@ -17,6 +17,26 @@ expect(mail.subject).to include(event_registration.event.title) end + context "when the recipient's zone differs from the event's zone" do + # Event pinned to Pacific: 19:00-20:00 UTC on 2031-06-15 = 12-1 pm PDT, but + # the email shows the recipient's own zone — 3-4 pm EDT for an Eastern user. + let(:event) do + create(:event, + time_zone: "Pacific Time (US & Canada)", + start_date: Time.utc(2031, 6, 15, 19, 0, 0), + end_date: Time.utc(2031, 6, 15, 20, 0, 0)) + end + let(:eastern_user) { create(:user, time_zone: "Eastern Time (US & Canada)") } + let(:registrant) { create(:person, user: eastern_user) } + let(:event_registration) { create(:event_registration, event: event, registrant: registrant) } + + it "renders the event time in the recipient's zone, not the event's" do + body = mail.body.encoded + expect(body).to include("3 - 4 pm EDT") + expect(body).not_to include("12 - 1 pm") + end + end + context "when a scholarship was not requested" do let(:event_registration) { create(:event_registration, scholarship_requested: false) } diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index 652dd435fe..bc386c796f 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -49,6 +49,41 @@ end end + describe "time zone" do + it "is invalid when the time_zone is not a recognized zone" do + event = build(:event, time_zone: "Mars/Olympus") + expect(event).not_to be_valid + expect(event.errors[:time_zone]).to include("is not a valid time zone") + end + + it "treats a blank time_zone as Pacific (no backfill needed for existing rows)" do + event = build(:event, time_zone: nil) + expect(event).to be_valid + expect(event.event_zone).to eq(ActiveSupport::TimeZone["Pacific Time (US & Canada)"]) + end + + it "interprets typed times in the event's own zone, not the session zone" do + # An Eastern admin editing a Pacific event: the 2:00 PM they type must be + # stored as 2:00 PM Pacific regardless of the request zone. + event = build(:event, time_zone: "Pacific Time (US & Canada)") + event.assign_attributes(start_date_date: "2026-09-14", start_date_time: "14:00") + + Time.use_zone("Eastern Time (US & Canada)") { event.valid? } + + expect(event.start_date.utc).to eq(Time.find_zone!("Pacific Time (US & Canada)").local(2026, 9, 14, 14, 0).utc) + end + + it "renders the form readers in the event's zone regardless of the session zone" do + event = create(:event, time_zone: "Pacific Time (US & Canada)", + start_date: Time.find_zone!("Pacific Time (US & Canada)").local(2026, 9, 14, 14, 0)) + + Time.use_zone("Eastern Time (US & Canada)") do + expect(event.start_date_time).to eq("14:00") + expect(event.start_date_date).to eq("2026-09-14") + end + end + end + describe "#date_title" do it "labels the event by date and title, without the time or parens" do event = build(:event, title: "Youth Creativity Day", start_date: Time.zone.local(2026, 9, 14, 14, 9)) diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 7c69211314..b8e6be586c 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -36,36 +36,34 @@ def offer_ce!(target_event) expect(response).to have_http_status(:ok) end - context "when user time_zone is set" do - # 19:00 UTC = 12:00 noon PT = 15:00 (3 pm) ET (June 15, 2031 with DST) - let(:utc_start) { Time.utc(2031, 6, 15, 19, 0, 0) } - let(:utc_end) { Time.utc(2031, 6, 15, 20, 0, 0) } - let!(:event_with_fixed_times) do + context "when the event has its own time zone" do + # 19:00-20:00 UTC = 12:00-1:00 pm PDT on June 15, 2031. The event is pinned + # to Pacific, so every viewer sees the same event-local time — it no longer + # shifts to the viewer's own zone. + let!(:pacific_event) do create(:event, :published, - start_date: utc_start, - end_date: utc_end, + time_zone: "Pacific Time (US & Canada)", + start_date: Time.utc(2031, 6, 15, 19, 0, 0), + end_date: Time.utc(2031, 6, 15, 20, 0, 0), title: "Timezone test event") end - it "displays start time in Pacific (PT) for user with time_zone PT" do - user_pt = create(:user) - sign_in user_pt - get event_url(event_with_fixed_times) + it "displays the event's zone to a Pacific viewer" do + sign_in create(:user, time_zone: "Pacific Time (US & Canada)") + get event_url(pacific_event) expect(response).to be_successful - # 19:00 UTC = 12:00 noon PT (styled format on show page) expect(response.body).to include("June 15, 2031") expect(response.body).to include("12 pm - 1 pm") + expect(response.body).to include("PDT") end - it "displays start time in Eastern for user with time_zone America/New_York" do - user_et = create(:user, time_zone: "Eastern Time (US & Canada)") - sign_in user_et - get event_url(event_with_fixed_times) - + it "displays the same event-zone time to an Eastern viewer (no per-viewer shift)" do + sign_in create(:user, time_zone: "Eastern Time (US & Canada)") + get event_url(pacific_event) expect(response).to be_successful - # 19:00 UTC = 3:00 pm ET (styled format on show page) expect(response.body).to include("June 15, 2031") - expect(response.body).to include("3 pm - 4 pm") + expect(response.body).to include("12 pm - 1 pm") + expect(response.body).to include("PDT") end end end @@ -640,13 +638,15 @@ def offer_ce!(target_event) expect(created.end_date.utc).to eq(Time.utc(2025, 6, 15, 20, 0, 0)) end - it "stores start_date/end_date in UTC when created by user in Eastern time zone" do + it "interprets typed times in the event's chosen zone, not the admin's session zone" do admin_et = create(:user, :admin, time_zone: "Eastern Time (US & Canada)") sign_in admin_et - # 15:00–16:00 ET (EDT) on 2025-06-15 = 19:00–20:00 UTC + # The admin's session is Eastern, but the event is set to Pacific — so + # 15:00–16:00 PT (PDT) on 2025-06-15 = 22:00–23:00 UTC. post events_url, params: { event: { - title: "ET event", + title: "PT event", description: "desc", + time_zone: "Pacific Time (US & Canada)", start_date_date: "2025-06-15", start_date_time: "15:00", end_date_date: "2025-06-15", @@ -657,8 +657,9 @@ def offer_ce!(target_event) created = Event.order(created_at: :desc).first expect(response).to redirect_to(event_url(created)) - expect(created.start_date.utc).to eq(Time.utc(2025, 6, 15, 19, 0, 0)) - expect(created.end_date.utc).to eq(Time.utc(2025, 6, 15, 20, 0, 0)) + expect(created.time_zone).to eq("Pacific Time (US & Canada)") + expect(created.start_date.utc).to eq(Time.utc(2025, 6, 15, 22, 0, 0)) + expect(created.end_date.utc).to eq(Time.utc(2025, 6, 15, 23, 0, 0)) end end diff --git a/spec/views/events/_form.html.erb_spec.rb b/spec/views/events/_form.html.erb_spec.rb index 5e709b5e9a..e396036ae9 100644 --- a/spec/views/events/_form.html.erb_spec.rb +++ b/spec/views/events/_form.html.erb_spec.rb @@ -28,6 +28,7 @@ expect(rendered).to have_selector("input[type='time'][name='event[end_date_time]']") expect(rendered).to have_selector("input[type='date'][name='event[registration_close_date_date]']") expect(rendered).to have_selector("input[type='time'][name='event[registration_close_date_time]']") + expect(rendered).to have_selector("select[name='event[time_zone]']") expect(rendered).to have_selector("input[type='checkbox'][name='event[published]']") end