From 1fc5d632b71b43f626131b2b3674c2058d408b4b Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 3 Aug 2026 20:09:38 -0400 Subject: [PATCH 1/4] Pin event times to an explicit per-event time zone Event datetimes were stored zone-lessly and interpreted in whoever's session zone at both entry and display, so an event created by a non-Pacific admin shifted hours for a Pacific viewer (and vice versa). Add an explicit events.time_zone so each event carries its own canonical zone; route entry, form readers, and all display through it. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/decorators/event_decorator.rb | 16 ++++----- app/mailers/event_mailer.rb | 8 ++--- app/models/event.rb | 36 +++++++++++++------ app/policies/event_policy.rb | 1 + app/views/events/_form.html.erb | 13 ++++++- app/views/events/show.html.erb | 4 +-- .../20260804000708_add_time_zone_to_events.rb | 9 +++++ db/schema.rb | 1 + 8 files changed, 63 insertions(+), 25 deletions(-) create mode 100644 db/migrate/20260804000708_add_time_zone_to_events.rb diff --git a/app/decorators/event_decorator.rb b/app/decorators/event_decorator.rb index 1456d5a269..8a2565e5a4 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,12 @@ 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) + s = start_date.in_time_zone(event_zone) + e = (end_date || start_date).in_time_zone(event_zone) tz_abbr = s.strftime("%Z") muted = styled ? "text-lg font-normal text-blue-400" : nil diff --git a/app/mailers/event_mailer.rb b/app/mailers/event_mailer.rb index a846ae0a6d..468788fa45 100644 --- a/app/mailers/event_mailer.rb +++ b/app/mailers/event_mailer.rb @@ -6,7 +6,7 @@ def event_registration_confirmation(event_registration) @notification_type = "Event registration confirmation" - @time_zone = @person.user&.time_zone || Time.zone.name + @time_zone = @event.time_zone @organization_name = ENV.fetch("ORGANIZATION_NAME", "AWBW") @organization_website = ENV.fetch("ORGANIZATION_WEBSITE", root_url) @@ -57,7 +57,7 @@ def event_registration_reminder(event_registration, custom_message: nil, custom_ @notification_type = "Event registration reminder" - @time_zone = @person.user&.time_zone || Time.zone.name + @time_zone = @event.time_zone @organization_name = ENV.fetch("ORGANIZATION_NAME", "AWBW") @organization_website = ENV.fetch("ORGANIZATION_WEBSITE", root_url) @@ -83,7 +83,7 @@ def event_registration_reminder_fyi(event, recipient_labels, custom_message: nil @recipient_labels = Array(recipient_labels) @custom_message = custom_message.presence @notification_type = "Event registration reminder" - @time_zone = Time.zone.name + @time_zone = @event.time_zone @organization_name = ENV.fetch("ORGANIZATION_NAME", "AWBW") count = @recipient_labels.size @@ -102,7 +102,7 @@ def event_registration_cancelled(event_registration) @notification_type = "Event registration cancellation" - @time_zone = @person.user&.time_zone || Time.zone.name + @time_zone = @event.time_zone @event_url = event_url(@event, reg: @event_registration.slug) @organization_name = ENV.fetch("ORGANIZATION_NAME", "AWBW") diff --git a/app/models/event.rb b/app/models/event.rb index 7b2be39a22..34bdcbcab4 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 @@ -212,33 +213,42 @@ 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) + # 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. Falls back to the app + # zone if the column is somehow blank/invalid. + def event_zone + ActiveSupport::TimeZone[time_zone.to_s] || 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 +325,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 ActiveSupport::TimeZone[time_zone.to_s] + + errors.add(:time_zone, "is not a valid time zone") end def registration_form_required_when_publicly_registerable 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/events/_form.html.erb b/app/views/events/_form.html.erb index 042b3c7ed2..6e614d2a74 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: "Pacific Time (US & Canada)" }, + 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..83c30709ab --- /dev/null +++ b/db/migrate/20260804000708_add_time_zone_to_events.rb @@ -0,0 +1,9 @@ +class AddTimeZoneToEvents < ActiveRecord::Migration[8.1] + def up + add_column :events, :time_zone, :string, null: false, default: "Pacific Time (US & Canada)" + 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..c25103be77 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", default: "Pacific Time (US & Canada)", null: false t.string "title" t.datetime "updated_at", null: false t.string "videoconference_label", default: "Virtual event" From 6a114cbc14dbb822a7e1c570a311e9dc49bab9d9 Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 3 Aug 2026 20:16:46 -0400 Subject: [PATCH 2/4] Add specs for per-event time zone entry and display Covers: zone validation, entry parsed in the event's zone (not the editor's session), form readers rendering in the event's zone, the new form selector, and updates the create request spec to the new semantics. Co-Authored-By: Claude Opus 4.8 (1M context) --- spec/factories/events.rb | 4 ++++ spec/models/event_spec.rb | 29 ++++++++++++++++++++++++ spec/requests/events_spec.rb | 13 +++++++---- spec/views/events/_form.html.erb_spec.rb | 1 + 4 files changed, 42 insertions(+), 5 deletions(-) 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/models/event_spec.rb b/spec/models/event_spec.rb index 652dd435fe..82531b7ac9 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -49,6 +49,35 @@ 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 "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..720e250574 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -640,13 +640,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 +659,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 From 08d11fae41486923874eba80dad1353cf6be4ba6 Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 3 Aug 2026 20:23:59 -0400 Subject: [PATCH 3/4] Route remaining event time rendering through the event's zone Sweep the helpers (registration-panel labels, form-header tokens, reminder subject, registration-close default) and the model identity labels (start_text, remote_search_label, date_title, day_count) plus EventRegistration#name off Time.zone and onto the event's own zone, so no event-derived date/time shifts with the viewer. Update the index request spec to assert the new no-per-viewer-shift behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/helpers/application_helper.rb | 20 ++++++++--------- app/models/event.rb | 10 ++++----- app/models/event_registration.rb | 2 +- spec/requests/events_spec.rb | 36 +++++++++++++++---------------- 4 files changed, 33 insertions(+), 35 deletions(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 99b790e99c..1da68f2019 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 @@ -121,8 +121,8 @@ def form_header_uses_tokens?(form) # "Thursday-Friday, July 23-24". Nil when the event has no start date. def event_dates_detail_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("%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}" @@ -151,8 +151,8 @@ def event_dates_label(event) # event show page's time formatting (minutes hidden when :00), or nil with no start. def event_times_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) 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 34bdcbcab4..fba8d19201 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -76,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 @@ -180,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 @@ -191,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 @@ -199,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 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/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 720e250574..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 From 84b0fadb6c20918700d4b5024ec93a007e0ef9aa Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 3 Aug 2026 22:33:26 -0400 Subject: [PATCH 4/4] Address review: nullable zone (empty=Pacific), mailers in recipient zone - Drop the NOT NULL default so existing rows stay NULL (no backfill); Event#event_zone reads a blank column as Pacific (DEFAULT_TIME_ZONE), and the form selector defaults there. - Revert mailers to the recipient's time zone. The shared display methods (EventDecorator#times and the event_dates_detail/times_label helpers) now take a zone: override defaulting to the event's zone; mailer templates pass the recipient zone so emails match the user. - No live-preview JS: the form's date/time preview is server-rendered (refreshes on save), so the zone abbreviation follows the same way. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/decorators/event_decorator.rb | 8 +++++--- app/helpers/application_helper.rb | 12 +++++------ app/mailers/event_mailer.rb | 8 ++++---- app/models/event.rb | 13 ++++++++---- .../event_mailer/_event_details_card.html.erb | 20 +++++++++---------- .../event_registration_cancelled.html.erb | 2 +- .../event_registration_cancelled.text.erb | 2 +- .../event_registration_confirmation.html.erb | 2 +- .../event_registration_confirmation.text.erb | 2 +- .../event_registration_reminder.text.erb | 12 +++++------ .../event_registration_reminder_fyi.text.erb | 6 +++--- app/views/events/_form.html.erb | 2 +- .../20260804000708_add_time_zone_to_events.rb | 5 ++++- db/schema.rb | 2 +- spec/mailers/event_mailer_spec.rb | 20 +++++++++++++++++++ spec/models/event_spec.rb | 6 ++++++ 16 files changed, 77 insertions(+), 45 deletions(-) diff --git a/app/decorators/event_decorator.rb b/app/decorators/event_decorator.rb index 8a2565e5a4..9c42836cc2 100644 --- a/app/decorators/event_decorator.rb +++ b/app/decorators/event_decorator.rb @@ -215,9 +215,11 @@ def multi_day? 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(event_zone) - e = (end_date || start_date).in_time_zone(event_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 1da68f2019..38e608307d 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -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(event.event_zone) - e = (event.end_date || event.start_date).in_time_zone(event.event_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")}" @@ -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(event.event_zone) - e = (event.end_date || event.start_date).in_time_zone(event.event_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" diff --git a/app/mailers/event_mailer.rb b/app/mailers/event_mailer.rb index 468788fa45..a846ae0a6d 100644 --- a/app/mailers/event_mailer.rb +++ b/app/mailers/event_mailer.rb @@ -6,7 +6,7 @@ def event_registration_confirmation(event_registration) @notification_type = "Event registration confirmation" - @time_zone = @event.time_zone + @time_zone = @person.user&.time_zone || Time.zone.name @organization_name = ENV.fetch("ORGANIZATION_NAME", "AWBW") @organization_website = ENV.fetch("ORGANIZATION_WEBSITE", root_url) @@ -57,7 +57,7 @@ def event_registration_reminder(event_registration, custom_message: nil, custom_ @notification_type = "Event registration reminder" - @time_zone = @event.time_zone + @time_zone = @person.user&.time_zone || Time.zone.name @organization_name = ENV.fetch("ORGANIZATION_NAME", "AWBW") @organization_website = ENV.fetch("ORGANIZATION_WEBSITE", root_url) @@ -83,7 +83,7 @@ def event_registration_reminder_fyi(event, recipient_labels, custom_message: nil @recipient_labels = Array(recipient_labels) @custom_message = custom_message.presence @notification_type = "Event registration reminder" - @time_zone = @event.time_zone + @time_zone = Time.zone.name @organization_name = ENV.fetch("ORGANIZATION_NAME", "AWBW") count = @recipient_labels.size @@ -102,7 +102,7 @@ def event_registration_cancelled(event_registration) @notification_type = "Event registration cancellation" - @time_zone = @event.time_zone + @time_zone = @person.user&.time_zone || Time.zone.name @event_url = event_url(@event, reg: @event_registration.slug) @organization_name = ENV.fetch("ORGANIZATION_NAME", "AWBW") diff --git a/app/models/event.rb b/app/models/event.rb index fba8d19201..7e07d282ea 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -213,11 +213,16 @@ def ce_hours_label registration_ticket_callouts.find_by(builtin_key: "ce_hours")&.title.presence || "CE hours" end + # 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. Falls back to the app - # zone if the column is somehow blank/invalid. + # 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.to_s] || Time.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 @@ -331,7 +336,7 @@ def build_datetime(date_str, time_str) end def time_zone_must_be_valid - return if ActiveSupport::TimeZone[time_zone.to_s] + return if time_zone.blank? || ActiveSupport::TimeZone[time_zone] errors.add(:time_zone, "is not a valid time zone") end 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 6e614d2a74..b414eddc58 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -84,7 +84,7 @@ <%= f.time_zone_select :time_zone, ActiveSupport::TimeZone.us_zones, - { default: "Pacific Time (US & Canada)" }, + { 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" %>
diff --git a/db/migrate/20260804000708_add_time_zone_to_events.rb b/db/migrate/20260804000708_add_time_zone_to_events.rb index 83c30709ab..2da3c21590 100644 --- a/db/migrate/20260804000708_add_time_zone_to_events.rb +++ b/db/migrate/20260804000708_add_time_zone_to_events.rb @@ -1,6 +1,9 @@ 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, null: false, default: "Pacific Time (US & Canada)" + add_column :events, :time_zone, :string end def down diff --git a/db/schema.rb b/db/schema.rb index c25103be77..622b603bd1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -563,7 +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", default: "Pacific Time (US & Canada)", null: false + t.string "time_zone" t.string "title" t.datetime "updated_at", null: false t.string "videoconference_label", default: "Virtual event" 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 82531b7ac9..bc386c796f 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -56,6 +56,12 @@ 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.