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 %>
Start date @@ -100,11 +99,10 @@ required: true %>
End date @@ -112,8 +110,8 @@ <% end %> -
+ + 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. +
+