diff --git a/app/assets/stylesheets/events/new/index.scss b/app/assets/stylesheets/events/new/index.scss index 87ae7a364f..f62a82b3c5 100644 --- a/app/assets/stylesheets/events/new/index.scss +++ b/app/assets/stylesheets/events/new/index.scss @@ -8,4 +8,9 @@ max-width: 270px; } +.timezone-select { + margin: 0 auto; + max-width: 350px; +} + .location-step .fieldsLayout--two { margin: 0 30px; } diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index cc9a132215..e78e0fd9be 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -29,19 +29,21 @@ def show end def create + Time.use_zone(params[:event][:timezone] || current_nonprofit.timezone) do + params[:event][:start_datetime] = Chronic.parse(params[:event][:start_datetime]) if params[:event][:start_datetime].present? + params[:event][:end_datetime] = Chronic.parse(params[:event][:end_datetime]) if params[:event][:end_datetime].present? + end + event = current_nonprofit.events.create(params[:event]) + render_json do - Time.use_zone(current_nonprofit.timezone || "UTC") do - params[:event][:start_datetime] = Chronic.parse(params[:event][:start_datetime]) if params[:event][:start_datetime].present? - params[:event][:end_datetime] = Chronic.parse(params[:event][:end_datetime]) if params[:event][:end_datetime].present? - end - flash[:notice] = "Your draft event has been created! Well done." - ev = current_nonprofit.events.create(params[:event]) - {url: "/events/#{ev.slug}", event: ev} + flash[:notice] = "Your draft event has been created! Well done." if event.persisted? + + { url: "/events/#{event.slug}", event: } end end def update - Time.use_zone(current_nonprofit.timezone || "UTC") do + Time.use_zone(current_event.timezone) do params[:event][:start_datetime] = Chronic.parse(params[:event][:start_datetime]) if params[:event][:start_datetime].present? params[:event][:end_datetime] = Chronic.parse(params[:event][:end_datetime]) if params[:event][:end_datetime].present? end @@ -75,6 +77,6 @@ def stats end def name_and_id - @events = current_nonprofit.events.not_deleted.order("events.name ASC") + @events = current_nonprofit.events.not_deleted.order("events.name ASC").select(:name, :id) end end diff --git a/app/legacy_lib/format/date.rb b/app/legacy_lib/format/date.rb index 181b9444bb..fa36b48d40 100644 --- a/app/legacy_lib/format/date.rb +++ b/app/legacy_lib/format/date.rb @@ -41,5 +41,9 @@ def self.time(datetime, timezone = nil) datetime = datetime.in_time_zone(timezone) if timezone datetime.strftime("%l:%M%P") end + + def self.timezone(timezone) + Time.now.in_time_zone(timezone).strftime('%Z') + end end end diff --git a/app/legacy_lib/query_event_metrics.rb b/app/legacy_lib/query_event_metrics.rb index 0a9450c5c4..d742302428 100644 --- a/app/legacy_lib/query_event_metrics.rb +++ b/app/legacy_lib/query_event_metrics.rb @@ -55,7 +55,8 @@ def self.for_listings(id_type, id, params) "events.start_datetime", "events.end_datetime", "events.organizer_email", - "events.in_person_or_virtual" + "events.in_person_or_virtual", + "events.timezone" ] exp = QueryEventMetrics.expression(selects) diff --git a/app/models/event.rb b/app/models/event.rb index 17e906acdf..40613d7cbf 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -33,7 +33,8 @@ class Event < ApplicationRecord :organizer_email, # string :receipt_message, # text :nonprofit, - :in_person_or_virtual + :in_person_or_virtual, + :timezone # string (timezone): event time zone if different from nonprofit timezone enum :in_person_or_virtual, %w[in_person virtual].index_by(&:itself), validate: true @@ -87,6 +88,8 @@ class Event < ApplicationRecord end self.published = false if published.nil? self.total_raised ||= 0 + self.timezone = nonprofit_timezone || "UTC" + self end @@ -123,4 +126,8 @@ def fee_coverage_option def get_tickets_button_label misc_event_info&.custom_get_tickets_button_label || "Get Tickets" end + + def timezone_with_fallback + timezone.presence || nonprofit_timezone + end end diff --git a/app/views/events/_date_time.html.erb b/app/views/events/_date_time.html.erb index 0d621f16e0..3c0be84390 100644 --- a/app/views/events/_date_time.html.erb +++ b/app/views/events/_date_time.html.erb @@ -3,7 +3,7 @@
Date & Time
- <%= Format::Date.full_range(@event.start_datetime, @event.end_datetime, @event.nonprofit_timezone) %> + <%= Format::Date.full_range(@event.start_datetime, @event.end_datetime, @event.timezone_with_fallback) %> <%= Format::Date.timezone(@event.timezone_with_fallback) %>
<% if Time.now > @event.end_datetime %> diff --git a/app/views/events/_edit_form.html.erb b/app/views/events/_edit_form.html.erb index b76baadf13..ff43be68d2 100644 --- a/app/views/events/_edit_form.html.erb +++ b/app/views/events/_edit_form.html.erb @@ -17,11 +17,12 @@
-
+
'>
+
@@ -37,6 +38,13 @@ Set
+ +
+ +
+ <%= select(:event, :timezone, time_zone_options_with_iana(@event.timezone), include_blank: 'Select Timezone') %> +
+

diff --git a/app/views/events/_listing.html.erb b/app/views/events/_listing.html.erb index deab5ec8fb..b4aa5ca7d5 100644 --- a/app/views/events/_listing.html.erb +++ b/app/views/events/_listing.html.erb @@ -1,8 +1,9 @@ <%- # License: AGPL-3.0-or-later WITH Web-Template-Output-Additional-Permission-3.0-or-later -%> -<% css = 'fundraiser--active' if css.nil? %> -
+
css.nil? ) %>'>
-

<%= Format::Date.full_range(event.start_datetime, event.end_datetime, event.nonprofit_timezone) %>

+

+ <%= Format::Date.full_range(event.start_datetime, event.end_datetime, event.timezone_with_fallback) %> <%= Format::Date.timezone(event.timezone_with_fallback) %> +

diff --git a/app/views/events/_new_modal.html.erb b/app/views/events/_new_modal.html.erb index bd81461b6e..4c5a41b939 100644 --- a/app/views/events/_new_modal.html.erb +++ b/app/views/events/_new_modal.html.erb @@ -44,23 +44,32 @@
-
+ -
- - -
+
+
+ +
+ + Set +
+
-
- -
- - Set -
+
+ +
+ + Set +
+
+
+ +
+ +
+ <%= select(:event, :timezone, time_zone_options_with_iana(@nonprofit.timezone), include_blank: 'Select Timezone', class: 'u-bold') %> +
<%= render 'components/forms/submit_button', button_text: 'Next', scope: 'new_event_wiz', branded: true %> @@ -68,8 +77,6 @@
- -
diff --git a/client/js/events/listing-item/index.js b/client/js/events/listing-item/index.js index ba0fae608a..ae6521c8c5 100644 --- a/client/js/events/listing-item/index.js +++ b/client/js/events/listing-item/index.js @@ -4,8 +4,8 @@ const h = require('snabbdom/h') const moment = require('moment-timezone') const {commaJoin} = require('./common'); -const dateTime = (startTime, endTime) => { - const tz = ENV.nonprofitTimezone || 'America/Los_Angeles' +const dateTime = (startTime, endTime, timeZone) => { + const tz = timeZone || ENV.nonprofitTimezone || 'America/Los_Angeles' startTime = moment(startTime).tz(tz) endTime = moment(endTime).tz(tz) const sameDate = startTime.format("YYYY-MM-DD") === endTime.format("YYYY-MM-DD") @@ -14,7 +14,7 @@ const dateTime = (startTime, endTime) => { const endTimeFormatted = sameDate ? endTime.format("h:mma") : endTime.format(format) return [ - h('strong', startTime.format(format) + ' - ' + endTimeFormatted) + h('strong', startTime.format(format) + ' - ' + endTimeFormatted + ' ' + moment.tz(tz).zoneAbbr()) , h('span.u-color--grey', ended) ] } @@ -67,7 +67,7 @@ module.exports = e => { return h('div.u-paddingTop--10.u-marginBottom--20', [ h('h5.u-paddingX--20', e.name) , h('table.table--striped.u-margin--0', [ - row('fa-clock-o', dateTime(e.start_datetime, e.end_datetime)) + row('fa-clock-o', dateTime(e.start_datetime, e.end_datetime, e.timezone)) , row('fa-map-marker', location) , row('fa-users', attendeesMetrics) , row('fa-dollar', moneyMetrics) diff --git a/db/migrate/20250910231648_add_timezone_to_events.rb b/db/migrate/20250910231648_add_timezone_to_events.rb new file mode 100644 index 0000000000..499751c0fe --- /dev/null +++ b/db/migrate/20250910231648_add_timezone_to_events.rb @@ -0,0 +1,7 @@ +class AddTimezoneToEvents < ActiveRecord::Migration[7.1] + def change + change_table :events do |t| + t.string :timezone + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 523e24ad50..31e49cf997 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2025_08_26_154514) do +ActiveRecord::Schema[7.1].define(version: 2025_09_10_231648) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" enable_extension "plpgsql" @@ -383,6 +383,7 @@ t.datetime "start_datetime", precision: nil t.datetime "end_datetime", precision: nil t.string "in_person_or_virtual", default: "in_person", comment: "whether or not this is a virtual event" + t.string "timezone" t.index ["in_person_or_virtual"], name: "index_events_on_in_person_or_virtual" t.index ["nonprofit_id", "deleted", "published", "end_datetime"], name: "events_nonprofit_id_not_deleted_and_published_endtime" t.index ["nonprofit_id", "deleted", "published"], name: "index_events_on_nonprofit_id_and_deleted_and_published" diff --git a/spec/lib/insert/insert_duplicate_spec.rb b/spec/lib/insert/insert_duplicate_spec.rb index d0fe2e3d41..606ec56cac 100644 --- a/spec/lib/insert/insert_duplicate_spec.rb +++ b/spec/lib/insert/insert_duplicate_spec.rb @@ -281,7 +281,8 @@ def set_event_start_time(start_time, end_time) { id: result.id, start_datetime: DateTime.new(2020, 5, 12), - end_datetime: DateTime.new(2020, 5, 12, 4) + end_datetime: DateTime.new(2020, 5, 12, 4), + timezone: "UTC" } ).with_indifferent_access) validate_tls(result) @@ -301,9 +302,9 @@ def set_event_start_time(start_time, end_time) expect(result.attributes.with_indifferent_access).to eq(common_result_attributes.merge( { id: result.id, - start_datetime: Time.utc(2020, 5, 12), - end_datetime: Time.utc(2020, 5, 12, 4) + end_datetime: Time.utc(2020, 5, 12, 4), + timezone: "UTC" } ).with_indifferent_access) @@ -323,7 +324,8 @@ def set_event_start_time(start_time, end_time) { id: result.id, start_datetime: event.start_datetime.to_time, - end_datetime: event.end_datetime.to_time + end_datetime: event.end_datetime.to_time, + timezone: "UTC" } ).with_indifferent_access) validate_tls(result) diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index 09ca19d0be..a4ff229cb3 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -2,6 +2,7 @@ require "rails_helper" RSpec.describe Event, type: :model do + it { is_expected.to belong_to(:nonprofit) } it { is_expected.to have_many(:ticketholders).through(:tickets).source(:supporter) } it { is_expected.to define_enum_for(:in_person_or_virtual).with_values(%w[in_person virtual].index_by(&:itself)).backed_by_column_of_type(:string).validating } it { is_expected.to delegate_method(:timezone).to(:nonprofit).with_prefix.allow_nil } @@ -18,4 +19,18 @@ it { is_expected.to_not validate_presence_of(:state_code) } end end + + describe "#timezone_with_fallback" do + let(:nonprofit) { create(:nonprofit_base, timezone: 'America/Los_Angeles') } + let(:event) { create(:event_base, nonprofit:, timezone: 'America/Central') } + let(:event_without_timezone) { create(:event_base, nonprofit:) } + + it "returns the event timezone with priority" do + expect(event.timezone_with_fallback).to eq('America/Central') + end + + it "returns the nonprofit timezone if event timezone is not set" do + expect(event_without_timezone.timezone_with_fallback).to eq(nonprofit.timezone) + end + end end diff --git a/spec/requests/events_request_spec.rb b/spec/requests/events_request_spec.rb index d4c712f212..4e09953bcd 100644 --- a/spec/requests/events_request_spec.rb +++ b/spec/requests/events_request_spec.rb @@ -2,34 +2,85 @@ require "rails_helper" describe EventsController, type: :request do - def event_setup - nonprofit = create(:nonprofit_base) + let(:nonprofit) { create(:nonprofit_base) } + let(:user) { create(:user_base, roles: [build(:role_base, name: "nonprofit_associate", host: nonprofit)]) } + let(:profile) { create(:profile, user:) } + let!(:events) do OpenStruct.new( - deleted: create(:event_base, nonprofit: nonprofit, deleted: true), - last: create(:event_base, nonprofit: nonprofit, name: "Last event"), - first: create(:event_base, nonprofit: nonprofit, name: "First event"), - nonprofit: nonprofit + deleted: create(:event_base, nonprofit:, deleted: true), + last: create(:event_base, nonprofit:, name: "Last event"), + first: create(:event_base, nonprofit:, name: "First event") ) end - def login_as_associate(nonprofit) - user = create(:user_base, roles: [build(:role_base, name: "nonprofit_associate", host: nonprofit)]) + before do sign_in user end - it "contains the events in order from first, to last with no deleted events" do - Event.any_instance.stub(:geocode).and_return([1, 1]) # otherwise the geocode call fails - events = event_setup + describe "POST /nonprofit/:nonprofit_id/events" do + before do + expect_any_instance_of(Event).to receive(:geocode).and_return([1, 1]) # otherwise the geocode call fails + end - login_as_associate(events.nonprofit) + context "with valid params" do + let(:params) { { event: attributes_for(:event_base, nonprofit_id: nonprofit.id, profile_id: profile.id) } } - get "/nonprofits/#{events.nonprofit.id}/events/name_and_id" + it "creates a new event" do + expect { post(nonprofit_events_path(nonprofit), params:) }.to change(Event, :count).by(1) + end - result = JSON.parse(response.body) + it "returns a success status" do + post(nonprofit_events_path(nonprofit), params:) + expect(response).to have_http_status(:success) + end - expect(result).to include_json([ - {name: events.first.name, id: events.first.id}, - {name: events.last.name, id: events.last.id} - ]) + it "sets the flash" do + post(nonprofit_events_path(nonprofit), params:) + expect(flash[:notice]).to eq("Your draft event has been created! Well done.") + end + + it "returns the response" do + post(nonprofit_events_path(nonprofit), params:) + json = JSON.parse(response.body) + expect(json.dig('event', 'name')).to eq(params[:event][:name]) + expect(json['url']).to eq("/events/#{params[:event][:slug]}") + end + end + + context "with invalid params" do + let(:params) { { event: attributes_for(:event_base).merge(name: nil) } } + + it "does not create a new event" do + expect { post(nonprofit_events_path(nonprofit), params:) }.not_to change(Event, :count) + end + + it "returns a success status" do + post(nonprofit_events_path(nonprofit), params:) + expect(response).to have_http_status(:success) + end + + it "returns the response" do + post(nonprofit_events_path(nonprofit), params:) + json = JSON.parse(response.body) + expect(json.dig('event', 'id')).to be_nil + expect(json.dig('event', 'name')).to be_nil + end + end + end + + + + + describe "GET name_and_id" do + it "contains the events in order from first, to last with no deleted events" do + get name_and_id_nonprofit_events_path(nonprofit) + + result = JSON.parse(response.body) + + expect(result).to include_json([ + {name: events.first.name, id: events.first.id}, + {name: events.last.name, id: events.last.id} + ]) + end end end