Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 53 additions & 7 deletions app/decorators/event_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,13 @@ def detail(length: nil)
end

def calendar_links
all_day = object.all_day?
start_time = object.start_date.utc.strftime("%Y%m%dT%H%M%SZ")
end_time = object.end_date.utc.strftime("%Y%m%dT%H%M%SZ")
# All-day calendar entries use bare dates; DTEND/end date is exclusive, so
# add a day to the last day the event runs.
start_day = object.start_date.in_time_zone.to_date
end_day_excl = object.end_date.in_time_zone.to_date + 1
title_encoded = ERB::Util.url_encode(object.title)

has_url = object.videoconference_url.present?
Expand Down Expand Up @@ -97,37 +102,48 @@ def calendar_links
desc_encoded = ERB::Util.url_encode(description)
location_encoded = ERB::Util.url_encode(cal_location.to_s)

ymd = ->(date) { date.strftime("%Y%m%d") }
ymd_dash = ->(date) { date.strftime("%Y-%m-%d") }

google_dates = all_day ? "#{ymd.call(start_day)}/#{ymd.call(end_day_excl)}" : "#{start_time}/#{end_time}"
apple_dtstart = all_day ? "DTSTART;VALUE=DATE:#{ymd.call(start_day)}\n" : "DTSTART:#{start_time}\n"
apple_dtend = all_day ? "DTEND;VALUE=DATE:#{ymd.call(end_day_excl)}\n" : "DTEND:#{end_time}\n"
outlook_start = all_day ? ymd_dash.call(start_day) : start_time
outlook_end = all_day ? ymd_dash.call(end_day_excl) : end_time
outlook_allday = all_day ? "&allday=true" : ""
yahoo_dates = all_day ? "&st=#{ymd.call(start_day)}&dur=allday" : "&st=#{start_time}&et=#{end_time}"

google_link =
"https://calendar.google.com/calendar/render?action=TEMPLATE" \
"&text=#{title_encoded}&dates=#{start_time}/#{end_time}" \
"&text=#{title_encoded}&dates=#{google_dates}" \
"&details=#{desc_encoded}&location=#{location_encoded}"

apple_link =
"data:text/calendar;charset=utf8,BEGIN:VCALENDAR\n" \
"VERSION:2.0\n" \
"BEGIN:VEVENT\n" \
"SUMMARY:#{object.title}\n" \
"DTSTART:#{start_time}\n" \
"DTEND:#{end_time}\n" \
"#{apple_dtstart}" \
"#{apple_dtend}" \
"DESCRIPTION:#{description}\n" \
"#{"LOCATION:#{cal_location}\n" if cal_location}" \
"END:VEVENT\n" \
"END:VCALENDAR"

outlook_link =
"https://outlook.live.com/owa/?rru=addevent" \
"&startdt=#{start_time}&enddt=#{end_time}" \
"&startdt=#{outlook_start}&enddt=#{outlook_end}#{outlook_allday}" \
"&subject=#{title_encoded}&body=#{desc_encoded}&location=#{location_encoded}"

office365_link =
"https://outlook.office.com/owa/?rru=addevent" \
"&startdt=#{start_time}&enddt=#{end_time}" \
"&startdt=#{outlook_start}&enddt=#{outlook_end}#{outlook_allday}" \
"&subject=#{title_encoded}&body=#{desc_encoded}&location=#{location_encoded}"

yahoo_link =
"https://calendar.yahoo.com/?v=60" \
"&title=#{title_encoded}&st=#{start_time}" \
"&et=#{end_time}&desc=#{desc_encoded}&in_loc=#{location_encoded}"
"&title=#{title_encoded}#{yahoo_dates}" \
"&desc=#{desc_encoded}&in_loc=#{location_encoded}"

h.safe_join(
[
Expand Down Expand Up @@ -169,6 +185,24 @@ def times(display_day: false, display_date: false, inline: false, styled: false)
full_date = ->(d) { d.strftime("%B %-d") }
wrap = ->(text, css) { css ? h.content_tag(:span, text, class: css) : text }

# All-day (no time entered) → render only the date / date range, no times.
if all_day?
if styled
return s.to_date == e.to_date ?
"#{full_day.call(s)}, #{full_date.call(s)}" :
all_day_date_range(s, e)
end

day_date = lambda do |d|
parts = []
parts << "#{day.call(d)}, " if display_day
parts << date.call(d)
h.safe_join(parts)
end
return day_date.call(s) if s.to_date == e.to_date
return h.safe_join([ day_date.call(s), day_date.call(e) ], " - ")
end

format_time = lambda do |d|
hour = d.strftime("%-l")
min = d.strftime("%M")
Expand Down Expand Up @@ -339,6 +373,18 @@ def format_zoom_meeting_id(id)
end
end

# Full-month date range for the styled (two-row) layout when an all-day event
# spans multiple days; collapses the month/year where they're shared.
def all_day_date_range(s, e)
if s.month == e.month && s.year == e.year
"#{s.strftime('%B')} #{s.strftime('%-d')}-#{e.strftime('%-d')}, #{s.strftime('%Y')}"
elsif s.year == e.year
"#{s.strftime('%B %-d')} - #{e.strftime('%B %-d')}, #{s.strftime('%Y')}"
else
"#{s.strftime('%B %-d, %Y')} - #{e.strftime('%B %-d, %Y')}"
end
end

def header_image
return unless object.rhino_header.body.present?

Expand Down
1 change: 1 addition & 0 deletions app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ 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
return if event.all_day?
s = event.start_date.in_time_zone(Time.zone)
e = (event.end_date || event.start_date).in_time_zone(Time.zone)
format = ->(d) do
Expand Down
22 changes: 20 additions & 2 deletions app/models/event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -189,15 +189,15 @@ def start_date_date
end

def start_date_time
@start_date_time || start_date&.strftime("%H:%M")
@start_date_time || time_field_value(start_date)
end

def end_date_date
@end_date_date || end_date&.strftime("%Y-%m-%d")
end

def end_date_time
@end_date_time || end_date&.strftime("%H:%M")
@end_date_time || time_field_value(end_date)
end

def registration_close_date_date
Expand All @@ -208,6 +208,15 @@ def registration_close_date_time
@registration_close_date_time || registration_close_date&.strftime("%H:%M")
end

# True when the event has a date but no specific clock time. The time inputs
# are optional; a blank time is persisted as midnight (00:00) and treated as
# "no time" everywhere dates/times render. Evaluated in the current Time.zone,
# so it reads correctly when viewed in the zone the event was authored in.
def all_day?
return false unless start_date
start_date.in_time_zone.strftime("%H:%M") == "00:00"
end

# Virtual attribute for cost in dollars (converts to/from cost_cents)
def cost
return nil if cost_cents.nil?
Expand Down Expand Up @@ -241,6 +250,15 @@ def to_partial_path

private

# Time-input value for a stored datetime, blank when the time is midnight so
# an all-day event (no time entered) shows an empty time field instead of
# "00:00" — keeping it editable as "no time" rather than re-saving midnight.
def time_field_value(datetime)
return if datetime.blank?
formatted = datetime.in_time_zone.strftime("%H:%M")
formatted unless formatted == "00:00"
end

def merge_date_time_fields
merge_date_time(:start_date)
merge_date_time(:end_date)
Expand Down
28 changes: 15 additions & 13 deletions app/views/events/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,10 @@
required: true %>
</div>
<div>
<label class="block text-xs text-gray-600 mb-0.5">Time</label>
<label class="block text-xs text-gray-600 mb-0.5">Time (optional)</label>
<%= f.text_field :start_date_time,
type: "time",
class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500",
required: true %>
class: "w-full rounded border-gray-300 bg-gray-100 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500" %>
</div>
<% if f.object.errors[:start_date].any? %>
<p class="text-red-500 text-sm mt-1">Start date
Expand All @@ -100,20 +99,19 @@
required: true %>
</div>
<div>
<label class="block text-xs text-gray-600 mb-0.5">Time</label>
<label class="block text-xs text-gray-600 mb-0.5">Time (optional)</label>
<%= f.text_field :end_date_time,
type: "time",
class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500",
required: true %>
class: "w-full rounded border-gray-300 bg-gray-100 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500" %>
</div>
<% if f.object.errors[:end_date].any? %>
<p class="text-red-500 text-sm mt-1">End date
<%= f.object.errors[:end_date].join(", ") %></p>
<% end %>
</div>

<div class="bg-white border border-gray-200 rounded-lg p-3">
<h3 class="font-medium mb-2">Registration closed</h3>
<div class="bg-gray-100 border border-gray-300 rounded-lg p-3">
<h3 class="font-medium mb-2">Registration close <span class="text-xs font-normal text-gray-500">(optional)</span></h3>
<% rc_default = @event.registration_close_date || event_registration_close_default(@event) %>
<div class="mb-2">
<label class="block text-xs text-gray-600 mb-0.5">Date</label>
Expand All @@ -123,7 +121,7 @@
value: rc_default.strftime("%Y-%m-%d") %>
</div>
<div>
<label class="block text-xs text-gray-600 mb-0.5">Time</label>
<label class="block text-xs text-gray-600 mb-0.5">Time (optional)</label>
<%= f.text_field :registration_close_date_time,
type: "time",
class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500",
Expand All @@ -138,12 +136,16 @@
</div>
</div>

<p class="-mt-1 text-sm text-gray-500">
<i class="fa-solid fa-circle-info text-gray-400"></i>
Leave a start or end time blank to show the event as all-day — no time
will appear anywhere the event is displayed. A blank time is saved as
12:00 AM, so an event can't be set to start exactly at midnight.
</p>

<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div class="flex items-center justify-between gap-2 mb-1">
<%= f.label :cost, "Event Cost", class: "block font-medium" %>
<span class="text-sm text-gray-600">Enter $0 if the event is free</span>
</div>
<%= f.label :cost, class: "block font-medium mb-1" do %>Event cost <span class="text-xs font-normal text-gray-500">Enter $0 if the event is free</span><% end %>

<div class="relative">
<span class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500">$</span>
Expand Down
2 changes: 1 addition & 1 deletion app/views/events/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
<%= event_dates_label(@event) %>
</div>
<% end %>
<% if @event.autoshow_time %>
<% if @event.autoshow_time && event_times_label(@event).present? %>
<div class="font-bold text-blue-900 mt-2" style="font-family: Lato, sans-serif; font-size: 24px">
<%= event_times_label(@event) %>
</div>
Expand Down
38 changes: 38 additions & 0 deletions spec/decorators/event_decorator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,21 @@
expect(apple["href"]).to include("Meeting ID: 882 8541 1273")
expect(apple["href"]).to include("Passcode: secret123")
end

it "emits all-day calendar entries when the event has no time" do
event = build(:event,
start_date: Time.zone.local(2026, 7, 23).beginning_of_day,
end_date: Time.zone.local(2026, 7, 23).beginning_of_day).decorate
doc = Nokogiri::HTML.fragment(event.calendar_links)
hrefs = doc.css("a").to_h { |a| [ a.text, a["href"] ] }

# End date is exclusive, so a single all-day day spans 07-23 to 07-24.
expect(hrefs["Google"]).to include("dates=20260723/20260724")
expect(hrefs["Apple"]).to include("DTSTART;VALUE=DATE:20260723")
expect(hrefs["Apple"]).to include("DTEND;VALUE=DATE:20260724")
expect(hrefs["Outlook"]).to include("startdt=2026-07-23&enddt=2026-07-24&allday=true")
expect(hrefs["Yahoo"]).to include("st=20260723&dur=allday")
end
end

describe "#times" do
Expand All @@ -187,4 +202,27 @@
expect(event.times(display_day: true, display_date: true)).to eq("Thu-Sat, Apr 30 - May 2 @ 9 am - 4:30 pm #{tz}")
end
end

describe "#times for all-day events" do
it "renders only the date when no time is set" do
event = build(:event,
start_date: Time.zone.local(2026, 7, 23).beginning_of_day,
end_date: Time.zone.local(2026, 7, 23).beginning_of_day).decorate
expect(event.times(display_day: true, display_date: true)).to eq("Thu, Jul 23")
end

it "renders only the date range for a multi-day all-day event" do
event = build(:event,
start_date: Time.zone.local(2026, 7, 23).beginning_of_day,
end_date: Time.zone.local(2026, 7, 24).beginning_of_day).decorate
expect(event.times(display_day: true, display_date: true)).to eq("Thu, Jul 23 - Fri, Jul 24")
end

it "omits the time line in the styled layout" do
event = build(:event,
start_date: Time.zone.local(2026, 7, 23).beginning_of_day,
end_date: Time.zone.local(2026, 7, 23).beginning_of_day).decorate
expect(event.times(styled: true)).to eq("Thursday, July 23")
end
end
end
13 changes: 13 additions & 0 deletions spec/helpers/application_helper_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,19 @@
end
end

describe "#event_times_label" do
it "shows the time range for an event with a time" do
event = build(:event, start_date: Time.zone.local(2026, 8, 12, 9), end_date: Time.zone.local(2026, 8, 12, 16, 30))
expect(helper.event_times_label(event)).to eq("9 am - 4:30 pm #{Time.zone.local(2026, 8, 12, 9).strftime("%Z")}")
end

it "is nil for an all-day event so the time row is hidden" do
event = build(:event, start_date: Time.zone.local(2026, 8, 12).beginning_of_day,
end_date: Time.zone.local(2026, 8, 12).beginning_of_day)
expect(helper.event_times_label(event)).to be_nil
end
end

describe "#event_platform_label" do
it "is nil for in-person events with no videoconference link" do
expect(helper.event_platform_label(build(:event, videoconference_url: nil))).to be_nil
Expand Down
34 changes: 34 additions & 0 deletions spec/models/event_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,40 @@
it { should validate_numericality_of(:cost_cents).is_greater_than_or_equal_to(0).allow_nil }
end

describe "#all_day?" do
it "is true when the start time is midnight (no time entered)" do
event = build(:event, start_date: Time.zone.now.beginning_of_day)
expect(event.all_day?).to be(true)
end

it "is false when the start has a specific time" do
event = build(:event, start_date: Time.zone.now.change(hour: 9, min: 30))
expect(event.all_day?).to be(false)
end

it "is false when there is no start date" do
event = build(:event, start_date: nil)
expect(event.all_day?).to be(false)
end
end

describe "optional time inputs" do
it "leaves the time field blank for an all-day event so it isn't shown as 00:00" do
event = build(:event, start_date: Time.zone.now.beginning_of_day,
end_date: Time.zone.now.beginning_of_day)
expect(event.start_date_time).to be_nil
expect(event.end_date_time).to be_nil
end

it "persists a blank time as midnight and stays valid as an all-day event" do
event = create(:event)
event.update!(start_date_date: "2026-07-23", start_date_time: "")
event.reload
expect(event.start_date.in_time_zone.strftime("%H:%M")).to eq("00:00")
expect(event.all_day?).to be(true)
end
end

describe "destroying with form submissions" do
it "is blocked and keeps the submission when the event has one" do
event = create(:event)
Expand Down
20 changes: 20 additions & 0 deletions spec/requests/events_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,26 @@
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))
end

it "creates an all-day event when the date is given but the time is left blank" do
admin_pt = create(:user, :admin, time_zone: "Pacific Time (US & Canada)")
sign_in admin_pt
post events_url, params: { event: {
title: "All-day event",
description: "desc",
start_date_date: "2026-07-23",
start_date_time: "",
end_date_date: "2026-07-23",
end_date_time: "",
registration_close_date: 1.day.ago,
public: true
} }

created = Event.order(created_at: :desc).first
expect(response).to redirect_to(event_url(created))
# Viewed in the zone it was authored in (PT), the blank time reads as all-day.
Time.use_zone("Pacific Time (US & Canada)") { expect(created.all_day?).to be(true) }
end
end

context "as non-admin" do
Expand Down
Loading