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