diff --git a/app/decorators/event_decorator.rb b/app/decorators/event_decorator.rb index c8606f05b4..495e05773e 100644 --- a/app/decorators/event_decorator.rb +++ b/app/decorators/event_decorator.rb @@ -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? @@ -97,9 +102,20 @@ 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 = @@ -107,8 +123,8 @@ def calendar_links "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" \ @@ -116,18 +132,18 @@ def calendar_links 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( [ @@ -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") @@ -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? diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 5d84242875..7766bce53c 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -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 diff --git a/app/models/event.rb b/app/models/event.rb index d73e54d788..8694b7b215 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -189,7 +189,7 @@ 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 @@ -197,7 +197,7 @@ def end_date_date 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 @@ -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? @@ -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) diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index b5790dc9fe..4bc272392c 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -78,11 +78,10 @@ required: true %>
- + <%= 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" %>
<% if f.object.errors[:start_date].any? %>

Start date @@ -100,11 +99,10 @@ required: true %>

- + <%= 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" %>
<% if f.object.errors[:end_date].any? %>

End date @@ -112,8 +110,8 @@ <% end %> -

-

Registration closed

+
+

Registration close (optional)

<% rc_default = @event.registration_close_date || event_registration_close_default(@event) %>
@@ -123,7 +121,7 @@ value: rc_default.strftime("%Y-%m-%d") %>
- + <%= 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", @@ -138,12 +136,16 @@
+

+ + 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. +

+
-
- <%= f.label :cost, "Event Cost", class: "block font-medium" %> - Enter $0 if the event is free -
+ <%= f.label :cost, class: "block font-medium mb-1" do %>Event cost Enter $0 if the event is free<% end %>
$ diff --git a/app/views/events/show.html.erb b/app/views/events/show.html.erb index 558d46cb29..95ec64bfe5 100644 --- a/app/views/events/show.html.erb +++ b/app/views/events/show.html.erb @@ -69,7 +69,7 @@ <%= event_dates_label(@event) %>
<% end %> - <% if @event.autoshow_time %> + <% if @event.autoshow_time && event_times_label(@event).present? %>
<%= event_times_label(@event) %>
diff --git a/spec/decorators/event_decorator_spec.rb b/spec/decorators/event_decorator_spec.rb index 08e7206f03..428c5c1394 100644 --- a/spec/decorators/event_decorator_spec.rb +++ b/spec/decorators/event_decorator_spec.rb @@ -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 @@ -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 diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 418a89c5c8..a94166375a 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -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 diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index 19903ba5d3..c64c104d02 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -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) diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 6366abd982..09b092a3c1 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -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 diff --git a/spec/views/events/_form.html.erb_spec.rb b/spec/views/events/_form.html.erb_spec.rb index 42aaee1173..889bd82f22 100644 --- a/spec/views/events/_form.html.erb_spec.rb +++ b/spec/views/events/_form.html.erb_spec.rb @@ -33,10 +33,10 @@ render expect(rendered).to have_selector("label", text: "Event title") - expect(rendered).to have_selector("label", text: "Event Cost") + expect(rendered).to have_selector("label", text: "Event cost") expect(rendered).to have_selector("h3", text: "Start") expect(rendered).to have_selector("h3", text: "End") - expect(rendered).to have_selector("h3", text: "Registration closed") + expect(rendered).to have_selector("h3", text: "Registration close") expect(rendered).to have_selector("label", text: "Published") end