From 81587d6f96244be11e7db347b6c6da84c39b3109 Mon Sep 17 00:00:00 2001 From: Georges Gabereau Date: Fri, 7 Aug 2026 19:22:24 -0400 Subject: [PATCH 1/2] Link event locations to Google Maps and keep events up until they end Every event block now carries an "Open in Google Maps" link under its address, on both the home page and the past events page. Events also stay on the home page until they're actually over rather than disappearing the moment they start. Meetups run about three hours, so Event::DURATION captures that and the upcoming/past scopes pivot on the end time. Calendar was already hard-coding the same three hours for the ICS dtend; it now shares Event#end_at so the feed and the site can't drift apart. --- app/models/calendar.rb | 2 +- app/models/event.rb | 35 +++++++++++- app/views/events/_event.html.erb | 9 ++- test/controllers/events_controller_test.rb | 54 ++++++++++++++++++ test/models/event_test.rb | 64 ++++++++++++++++++++++ 5 files changed, 160 insertions(+), 4 deletions(-) create mode 100644 test/models/event_test.rb 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..318d17d 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -1,10 +1,14 @@ 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 @@ -13,6 +17,20 @@ def start_time start_at.in_time_zone('Eastern Time (US & Canada)').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 statuses.map { |k, _v| [k.titleize, k] } end @@ -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/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/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 From c5d9f81d15827c9652333b816eaac15893d08604 Mon Sep 17 00:00:00 2001 From: Georges Gabereau Date: Fri, 7 Aug 2026 19:23:52 -0400 Subject: [PATCH 2/2] Render times in Toronto time instead of UTC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin dashboard listed every event three to five hours ahead of when it actually starts — an 8pm meetup showed as midnight or 1am the next day. The app never set config.time_zone, so it defaulted to UTC and any bare strftime rendered UTC. Setting the zone once fixes it at the root and lets three scattered workarounds go: Event#start_time, the admin form's start_at value, and the controller's TZ_STRING round-trip on create/update. Storage is unchanged — ActiveRecord still persists UTC. --- app/controllers/admin/events_controller.rb | 3 --- app/models/event.rb | 2 +- app/views/admin/events/_form.html.erb | 2 +- config/application.rb | 3 ++- .../admin/events_controller_test.rb | 21 +++++++++++++++++++ 5 files changed, 25 insertions(+), 6 deletions(-) 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/event.rb b/app/models/event.rb index 318d17d..6d0e11f 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -14,7 +14,7 @@ class Event < ApplicationRecord 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 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/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)