diff --git a/app/controllers/admin/events_controller.rb b/app/controllers/admin/events_controller.rb index c6bd2c6..ca3f3da 100644 --- a/app/controllers/admin/events_controller.rb +++ b/app/controllers/admin/events_controller.rb @@ -1,6 +1,5 @@ module Admin class EventsController < BaseController - TZ_STRING = 'Eastern Time (US & Canada)' before_action :set_event, only: %i[show edit update destroy] rescue_from ActiveRecord::RecordNotFound, with: :record_not_found @@ -21,7 +20,6 @@ def show; end # POST /events or /events.json def create @event = Event.new(event_params) - @event.start_at = @event.start_at.change(zone: TZ_STRING) respond_to do |format| if @event.save @@ -37,7 +35,6 @@ def create # PATCH/PUT /events/1 or /events/1.json def update @event.assign_attributes(event_params) - @event.start_at = @event.start_at.change(zone: TZ_STRING) respond_to do |format| if @event.save diff --git a/app/models/calendar.rb b/app/models/calendar.rb index c8a4bc0..4fda195 100644 --- a/app/models/calendar.rb +++ b/app/models/calendar.rb @@ -17,7 +17,7 @@ def publish @events.each do |event| calendar.event do |e| e.dtstart = ical_time(event.start_at) - e.dtend = ical_time(event.start_at + 3.hours) + e.dtend = ical_time(event.end_at) e.summary = "Toronto Ruby - #{event.name}" e.location = event.city e.url = event.rsvp_link || event_url(event) diff --git a/app/models/event.rb b/app/models/event.rb index 011bac7..6d0e11f 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -1,16 +1,34 @@ class Event < ApplicationRecord + # Meetups run about three hours; we don't track an explicit end time. + DURATION = 3.hours + validates :start_at, :name, :location, :description, presence: true enum :status, { draft: 0, published: 1 }, default: :draft - scope :upcoming, -> { published.where(start_at: Time.zone.now...).order(start_at: :asc) } - scope :past, -> { published.where(start_at: ...Time.zone.now).order(start_at: :desc) } + # An event counts as upcoming until it's over, not until it starts. + scope :upcoming, -> { published.where(start_at: (Time.zone.now - DURATION)...).order(start_at: :asc) } + scope :past, -> { published.where(start_at: ...(Time.zone.now - DURATION)).order(start_at: :desc) } has_rich_text :description has_rich_text :location def start_time - start_at.in_time_zone('Eastern Time (US & Canada)').to_fs(:long_at) + start_at.to_fs(:long_at) + end + + def end_at + start_at + DURATION + end + + def over? + Time.zone.now >= end_at + end + + def map_url + return if map_query.blank? + + "https://www.google.com/maps/search/?#{{ api: 1, query: map_query }.to_query}" end def self.statuses_for_select @@ -20,4 +38,17 @@ def self.statuses_for_select def to_param slug end + + private + + # Locations are written as a venue name, then a street address, then optional + # arrival instructions. Only the first two lines help a map search, so the + # rest is dropped and the city is appended when it isn't already there. + def map_query + lines = location&.to_plain_text.to_s.split("\n").map(&:strip).reject(&:blank?).first(2) + return '' if lines.blank? + + lines << city if city.present? && lines.none? { |line| line.include?(city.split(',').first.strip) } + lines.join(', ') + end end diff --git a/app/views/admin/events/_form.html.erb b/app/views/admin/events/_form.html.erb index e50a6cc..3f0f1c0 100644 --- a/app/views/admin/events/_form.html.erb +++ b/app/views/admin/events/_form.html.erb @@ -38,7 +38,7 @@ <%= form.label :start_at, class: "block text-sm font-medium text-gray-700 mb-1" %> <%= form.datetime_field :start_at, include_seconds: false, - value: event&.start_at&.in_time_zone("Eastern Time (US & Canada)").presence || Time.zone.now.in_time_zone("Eastern Time (US & Canada)"), + value: event&.start_at || Time.zone.now, class: "block w-full px-4 py-3 rounded-lg border border-gray-300 shadow-sm focus:border-ruby-500 focus:ring-1 focus:ring-ruby-500 transition-colors" %>

Eastern Time (US & Canada)

diff --git a/app/views/events/_event.html.erb b/app/views/events/_event.html.erb index cea8976..eacfb66 100644 --- a/app/views/events/_event.html.erb +++ b/app/views/events/_event.html.erb @@ -1,5 +1,5 @@
- <% future = Time.zone.now < event.start_at %> + <% future = !event.over? %>
@@ -94,6 +94,13 @@
<%= event.location %>
+ <% if event.map_url %> + <%= link_to "Open in Google Maps", + event.map_url, + class: "external-link mt-2 text-sm", + target: "_blank", + rel: "noopener" %> + <% end %>
diff --git a/config/application.rb b/config/application.rb index 008877d..50f3b2b 100644 --- a/config/application.rb +++ b/config/application.rb @@ -33,7 +33,8 @@ class Application < Rails::Application # These settings can be overridden in specific environments using the files # in config/environments, which are processed later. # - # config.time_zone = "Central Time (US & Canada)" + # Every meetup is in Toronto, so render times there. Storage stays UTC. + config.time_zone = 'Eastern Time (US & Canada)' # config.eager_load_paths << Rails.root.join("extras") # Don't generate system test files. diff --git a/test/controllers/admin/events_controller_test.rb b/test/controllers/admin/events_controller_test.rb index 2e18c8a..8694b3b 100644 --- a/test/controllers/admin/events_controller_test.rb +++ b/test/controllers/admin/events_controller_test.rb @@ -38,6 +38,27 @@ def setup assert response.body.include?(Event.last.name) end + test 'index shows event times in Toronto time, not UTC' do + Event.destroy_all + # 00:30 UTC is the previous evening in Toronto. + Event.create!( + start_at: Time.utc(2024, 11, 26, 0, 30), + name: 'Late Night Edition', + location: 'Some Office', + description: 'A talk', + status: :published, + sponsor: 'Some Sponsor', + sponsor_link: 'https://example.com' + ) + + get admin_events_path + + assert_response :success + assert_match 'November 25, 2024', response.body + assert_match '7:30 PM', response.body + assert_no_match(/November 26, 2024/, response.body) + end + test 'should show a single event' do get admin_event_path(Event.first.slug) diff --git a/test/controllers/events_controller_test.rb b/test/controllers/events_controller_test.rb index bfd70e1..1e298f0 100644 --- a/test/controllers/events_controller_test.rb +++ b/test/controllers/events_controller_test.rb @@ -59,4 +59,58 @@ def setup assert_response :found assert_redirected_to all_events_path end + + test 'links the location to Google Maps' do + event = @events.first + event.update!(location: "Workplace One\n51 Wolseley St, Toronto ON") + + get :show, params: { slug: event.slug } + + assert_response :success + assert_match 'https://www.google.com/maps/search/?api=1&query=Workplace+One%2C+51+Wolseley+St%2C+Toronto+ON', + response.body + assert_match 'Open in Google Maps', response.body + end + + test 'an event still in progress stays on the home page' do + create_only_event('In Progress Event', Time.zone.now - 1.hour) + + get :index + assert_response :success + assert_match 'In Progress Event', response.body + assert_match 'Upcoming', response.body + + get :past + assert_response :success + assert_match 'No past events', response.body + end + + test 'an event that has ended moves to past events' do + create_only_event('Finished Event', Time.zone.now - (Event::DURATION + 1.minute)) + + get :index + assert_response :success + assert_match "We're planning our next outing", response.body + + get :past + assert_response :success + assert_match 'Finished Event', response.body + assert_match 'Past Event', response.body + end + + private + + def create_only_event(name, start_at) + Event.destroy_all + Event.create!( + start_at: start_at, + name: name, + location: 'Some Office', + description: 'A talk', + status: :published, + rsvp_link: 'https://example.com/rsvp', + sponsor: 'Some Sponsor', + sponsor_link: 'https://example.com' + ) + end end diff --git a/test/models/event_test.rb b/test/models/event_test.rb new file mode 100644 index 0000000..0fc48e6 --- /dev/null +++ b/test/models/event_test.rb @@ -0,0 +1,64 @@ +require 'test_helper' + +class EventTest < ActiveSupport::TestCase + def build_event(attributes = {}) + Event.new({ + name: 'Witty Event Name', + location: "Workplace One\n51 Wolseley St, Toronto ON\nLower level, enter through doors on Wolseley St.", + description: 'A talk', + rsvp_link: 'https://test.com', + status: :published, + start_at: Time.zone.parse('2024-11-26T00:30Z') + }.merge(attributes)) + end + + test 'end_at is three hours after start_at' do + event = build_event(start_at: Time.zone.parse('2024-11-25T19:30-05:00')) + + assert_equal Time.zone.parse('2024-11-25T22:30-05:00'), event.end_at + end + + test 'over? flips at the end time, not the start time' do + event = build_event(start_at: Time.zone.now - 2.hours) + assert_not event.over? + + event.start_at = Time.zone.now - 4.hours + assert event.over? + end + + test 'map_url builds a Google Maps search from the venue and street address' do + event = build_event + + assert_equal 'https://www.google.com/maps/search/?api=1&query=Workplace+One%2C+51+Wolseley+St%2C+Toronto+ON', + event.map_url + end + + test 'map_url appends the city when the address does not name it' do + event = build_event(location: "FinanceIt @ The Well\n8 Spadina Ave\nSuite 2400", city: 'Toronto, Canada') + + assert_equal 'https://www.google.com/maps/search/?api=1&query=FinanceIt+%40+The+Well%2C+8+Spadina+Ave%2C+Toronto%2C+Canada', + event.map_url + end + + test 'map_url is nil without a location' do + assert_nil build_event(location: '').map_url + end + + test 'an in-progress event is upcoming, not past' do + Event.destroy_all + event = build_event(start_at: Time.zone.now - 1.hour) + event.save! + + assert_includes Event.upcoming, event + assert_not_includes Event.past, event + end + + test 'a finished event is past, not upcoming' do + Event.destroy_all + event = build_event(start_at: Time.zone.now - (Event::DURATION + 1.minute)) + event.save! + + assert_includes Event.past, event + assert_not_includes Event.upcoming, event + end +end