Skip to content

Commit 1bc5a39

Browse files
authored
Enhancements/events (#1073)
This pull request introduces several enhancements and refactors to the BetterTogether event management features, focusing on improving event time handling, optimizing database queries, enhancing privacy display consistency, and refining RSVP logic. It also adds new helper methods for UI consistency and performance, and updates documentation and linting rules. **Event Management & RSVP Improvements** * Refactored RSVP actions in `events_controller.rb` to ensure only scheduled events allow RSVP, require login for RSVP/cancel actions, and provide user feedback via redirects and alerts. [[1]](diffhunk://#diff-ffc530a02a78834a7cc82d089dee80fa08f52d911015abfa0c563543b9fce194L38-R60) [[2]](diffhunk://#diff-ffc530a02a78834a7cc82d089dee80fa08f52d911015abfa0c563543b9fce194L77-R119) * Added preloading of event associations (categories, hosts, attendances, translations, images, location) in `set_resource_instance` to avoid N+1 queries and improve event page performance. * Added AJAX card rendering for event show requests, improving responsiveness for event hover cards. **Event Time Handling** * Added a new Stimulus controller (`event_datetime_controller.js`) for dynamic synchronization of event start time, end time, and duration fields, enforcing minimum duration and providing user feedback. * Introduced the `display_event_time` helper to intelligently format event times based on duration and date span for clearer UI display. **Privacy Display Consistency** * Added `privacy_display_value` helper for consistent, translated privacy level display across the application, and updated `privacy_badge` to use this helper. [[1]](diffhunk://#diff-42549e834458635fd46c3c7f70c4278eae0c631ec6db068ea95c0f9916de5325R15-R22) [[2]](diffhunk://#diff-42549e834458635fd46c3c7f70c4278eae0c631ec6db068ea95c0f9916de5325L32-R40) [[3]](diffhunk://#diff-42a55df7b5b86f1d10011c781d511e1158047cc673b277f34091e0567e37141cR83-R96) **Performance Optimizations** * Preloaded calendar associations for people in `people_controller.rb` to avoid N+1 queries. **UI Consistency & Documentation** * Added `event_relationship_icon` helper to display appropriate icons and colors for a person's relationship to an event. * Updated view helper documentation to include privacy display best practices. * Disabled Rubocop's `RSpec/MultipleExpectations` cop to allow multiple expectations per test.
2 parents e7da118 + 945de47 commit 1bc5a39

File tree

93 files changed

+2299
-307
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

93 files changed

+2299
-307
lines changed

.github/instructions/view-helpers.instructions.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,20 @@ end
8080
- `truncate`, `pluralize`, `excerpt`, `word_wrap`, `simple_format`.
8181
- For rich text (Action Text), use `record.body` and `to_plain_text` for indexing/search.
8282

83+
### Privacy Display
84+
- Use `privacy_display_value(entity)` for consistent, translated privacy level display across the application.
85+
- This helper automatically looks up the proper translation from `attributes.privacy_list` and falls back to humanized values.
86+
- Supports all privacy levels: `public`, `private`, `community`, `unlisted`.
87+
88+
```ruby
89+
# Instead of: entity.privacy.humanize or entity.privacy.capitalize
90+
<%= privacy_display_value(@event) %> # "Public" / "Público" / "Public"
91+
<%= privacy_display_value(@community) %> # "Private" / "Privado" / "Privé"
92+
93+
# Works in badges too (automatically used)
94+
<%= privacy_badge(@entity) %> # Uses privacy_display_value internally
95+
```
96+
8397
---
8498
8599
## 4. Navigation & Link Helpers

.rubocop.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ plugins:
1313
- rubocop-rspec_rails
1414
- rubocop-capybara
1515
- rubocop-factory_bot
16+
RSpec/MultipleExpectations:
17+
Enabled: false
1618
Style/StringLiterals:
1719
Exclude:
1820
- 'db/migrate/*'
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Event DateTime Controller
2+
// Handles dynamic synchronization between start time, end time, and duration fields
3+
//
4+
// Behavior:
5+
// - When start time changes: Updates end time based on current duration
6+
// - When end time changes: Updates duration based on start/end time difference
7+
// - When duration changes: Updates end time based on start time + duration
8+
// - Validates minimum duration (5 minutes)
9+
// - Defaults duration to 30 minutes when not set
10+
11+
import { Controller } from "@hotwired/stimulus"
12+
13+
export default class extends Controller {
14+
static targets = ["startTime", "endTime", "duration"]
15+
16+
connect() {
17+
// Set default duration if not already set
18+
if (!this.durationTarget.value || this.durationTarget.value === "0") {
19+
this.durationTarget.value = "30"
20+
}
21+
22+
// Initialize end time if start time is set but end time is not
23+
if (this.startTimeTarget.value && !this.endTimeTarget.value) {
24+
this.updateEndTimeFromDuration()
25+
}
26+
}
27+
28+
// Called when start time changes
29+
updateEndTime() {
30+
if (!this.startTimeTarget.value) {
31+
this.endTimeTarget.value = ""
32+
return
33+
}
34+
35+
// Use current duration or default to 30 minutes
36+
const duration = this.getDurationInMinutes()
37+
this.calculateEndTime(duration)
38+
}
39+
40+
// Called when end time changes
41+
updateDuration() {
42+
if (!this.startTimeTarget.value || !this.endTimeTarget.value) {
43+
return
44+
}
45+
46+
const startTime = new Date(this.startTimeTarget.value)
47+
const endTime = new Date(this.endTimeTarget.value)
48+
49+
// Validate end time is after start time
50+
if (endTime <= startTime) {
51+
this.showTemporaryError("End time must be after start time")
52+
return
53+
}
54+
55+
// Calculate duration in minutes
56+
const diffInMs = endTime.getTime() - startTime.getTime()
57+
const diffInMinutes = Math.round(diffInMs / (1000 * 60))
58+
59+
// Enforce minimum duration
60+
if (diffInMinutes < 5) {
61+
this.durationTarget.value = "5"
62+
this.calculateEndTime(5)
63+
} else {
64+
this.durationTarget.value = diffInMinutes.toString()
65+
}
66+
}
67+
68+
// Called when duration changes
69+
updateEndTimeFromDuration() {
70+
if (!this.startTimeTarget.value) {
71+
return
72+
}
73+
74+
const duration = this.getDurationInMinutes()
75+
76+
// Enforce minimum duration
77+
if (duration < 5) {
78+
this.durationTarget.value = "5"
79+
this.calculateEndTime(5)
80+
} else {
81+
this.calculateEndTime(duration)
82+
}
83+
}
84+
85+
// Helper methods
86+
getDurationInMinutes() {
87+
const duration = parseInt(this.durationTarget.value) || 30
88+
return Math.max(duration, 5) // Minimum 5 minutes
89+
}
90+
91+
calculateEndTime(durationMinutes) {
92+
if (!this.startTimeTarget.value) return
93+
94+
const startTime = new Date(this.startTimeTarget.value)
95+
const endTime = new Date(startTime.getTime() + (durationMinutes * 60 * 1000))
96+
97+
// Format for datetime-local input (YYYY-MM-DDTHH:MM)
98+
const year = endTime.getFullYear()
99+
const month = String(endTime.getMonth() + 1).padStart(2, '0')
100+
const day = String(endTime.getDate()).padStart(2, '0')
101+
const hours = String(endTime.getHours()).padStart(2, '0')
102+
const minutes = String(endTime.getMinutes()).padStart(2, '0')
103+
104+
this.endTimeTarget.value = `${year}-${month}-${day}T${hours}:${minutes}`
105+
}
106+
107+
showTemporaryError(message) {
108+
// Create or update error message
109+
let errorElement = this.element.querySelector('.datetime-sync-error')
110+
111+
if (!errorElement) {
112+
errorElement = document.createElement('div')
113+
errorElement.className = 'alert alert-warning datetime-sync-error mt-2'
114+
errorElement.setAttribute('role', 'alert')
115+
this.element.appendChild(errorElement)
116+
}
117+
118+
errorElement.textContent = message
119+
120+
// Remove error after 3 seconds
121+
setTimeout(() => {
122+
if (errorElement && errorElement.parentNode) {
123+
errorElement.parentNode.removeChild(errorElement)
124+
}
125+
}, 3000)
126+
}
127+
}

app/controllers/better_together/agreements_controller.rb

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,22 @@ class AgreementsController < FriendlyResourceController
99
# return only the fragment wrapped in the expected <turbo-frame id="agreement_modal_frame">...</turbo-frame>
1010
# so Turbo can swap it into the frame. For normal requests, fall back to the
1111
# default rendering (with layout).
12-
def show
12+
def show # rubocop:todo Metrics/MethodLength
1313
if @agreement.page
1414
@page = @agreement.page
1515
@content_blocks = @page.content_blocks
1616
@layout = 'layouts/better_together/page'
1717
@layout = @page.layout if @page.layout.present?
1818
end
1919

20-
return unless turbo_frame_request?
21-
22-
content = render_to_string(action: :show, layout: false)
23-
render html: view_context.turbo_frame_tag('agreement_modal_frame', content)
20+
# Check if this is a Turbo Frame request
21+
if request.headers['Turbo-Frame'].present?
22+
Rails.logger.debug 'Rendering turbo frame response'
23+
render partial: 'modal_content', layout: false
24+
else
25+
Rails.logger.debug 'Rendering normal response'
26+
# Normal full-page rendering continues with the view
27+
end
2428
end
2529

2630
protected

app/controllers/better_together/events_controller.rb

Lines changed: 95 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
module BetterTogether
44
# CRUD for BetterTogether::Event
5-
class EventsController < FriendlyResourceController
5+
class EventsController < FriendlyResourceController # rubocop:todo Metrics/ClassLength
66
before_action if: -> { Rails.env.development? } do
77
# Make sure that all subclasses are loaded in dev to generate type selector
88
Rails.application.eager_load!
@@ -17,6 +17,14 @@ def index
1717
end
1818

1919
def show
20+
# Handle AJAX requests for card format - only our specific hover card requests
21+
card_request = request.headers['X-Card-Request'] == 'true' || request.headers['HTTP_X_CARD_REQUEST'] == 'true'
22+
23+
if request.xhr? && card_request
24+
render partial: 'better_together/events/event', locals: { event: @event }, layout: false
25+
return
26+
end
27+
2028
super
2129
end
2230

@@ -35,10 +43,21 @@ def rsvp_going
3543
rsvp_update('going')
3644
end
3745

38-
def rsvp_cancel
39-
@event = set_resource_instance
46+
def rsvp_cancel # rubocop:disable Metrics/MethodLength
47+
set_resource_instance
48+
return if performed? # Exit early if 404 was already rendered
49+
50+
@event = @resource
4051
authorize @event, :show?
41-
attendance = BetterTogether::EventAttendance.find_by(event: @event, person: helpers.current_person)
52+
53+
# Ensure current_person exists
54+
current_person = helpers.current_person
55+
unless current_person
56+
redirect_to @event, alert: t('better_together.events.login_required', default: 'Please log in to manage RSVPs.')
57+
return
58+
end
59+
60+
attendance = BetterTogether::EventAttendance.find_by(event: @event, person: current_person)
4261
attendance&.destroy
4362
redirect_to @event, notice: t('better_together.events.rsvp_cancelled', default: 'RSVP cancelled')
4463
end
@@ -74,10 +93,30 @@ def resource_class
7493

7594
private
7695

77-
def rsvp_update(status)
78-
@event = set_resource_instance
96+
# rubocop:todo Metrics/MethodLength
97+
def rsvp_update(status) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
98+
set_resource_instance
99+
return if performed? # Exit early if 404 was already rendered
100+
101+
@event = @resource
79102
authorize @event, :show?
80-
attendance = BetterTogether::EventAttendance.find_or_initialize_by(event: @event, person: helpers.current_person)
103+
104+
# Check if event allows RSVP
105+
unless @event.scheduled?
106+
redirect_to @event,
107+
alert: t('better_together.events.rsvp_not_available',
108+
default: 'RSVP is not available for this event.')
109+
return
110+
end
111+
112+
# Ensure current_person exists before creating attendance
113+
current_person = helpers.current_person
114+
unless current_person
115+
redirect_to @event, alert: t('better_together.events.login_required', default: 'Please log in to RSVP.')
116+
return
117+
end
118+
119+
attendance = BetterTogether::EventAttendance.find_or_initialize_by(event: @event, person: current_person)
81120
attendance.status = status
82121
authorize attendance
83122
if attendance.save
@@ -86,5 +125,54 @@ def rsvp_update(status)
86125
redirect_to @event, alert: attendance.errors.full_messages.to_sentence
87126
end
88127
end
128+
# rubocop:enable Metrics/MethodLength
129+
130+
# Override base controller method to add performance optimizations
131+
def set_resource_instance
132+
super
133+
134+
# Preload associations needed for event show page to avoid N+1 queries
135+
preload_event_associations! unless json_request?
136+
end
137+
138+
def json_request?
139+
request.format.json?
140+
end
141+
142+
# rubocop:todo Metrics/AbcSize
143+
# rubocop:todo Metrics/MethodLength
144+
def preload_event_associations! # rubocop:todo Metrics/CyclomaticComplexity, Metrics/AbcSize
145+
return unless @event
146+
147+
# Preload categories and their translations to avoid N+1 queries
148+
@event.categories.includes(:string_translations).load
149+
150+
# Preload event hosts and their associated models
151+
@event.event_hosts.includes(:host).load
152+
153+
# Preload event attendances to avoid count queries in view
154+
@event.event_attendances.includes(:person).load
155+
156+
# Preload current person's attendance for RSVP buttons
157+
if current_person
158+
@current_attendance = @event.event_attendances.find do |a|
159+
a.person_id == current_person.id
160+
end
161+
end
162+
163+
# Preload translations for the event itself
164+
@event.string_translations.load
165+
@event.text_translations.load
166+
167+
# Preload cover image attachment to avoid attachment queries
168+
@event.cover_image_attachment&.blob&.load if @event.cover_image.attached?
169+
170+
# Preload location if present
171+
@event.location&.reload
172+
173+
self
174+
end
175+
# rubocop:enable Metrics/MethodLength
176+
# rubocop:enable Metrics/AbcSize
89177
end
90178
end

app/controllers/better_together/people_controller.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ def show
1717
:string_translations,
1818
blocks: { background_image_file_attachment: :blob }
1919
)
20+
21+
# Preload calendar associations to avoid N+1 queries
22+
@person.preload_calendar_associations!
2023
end
2124

2225
# GET /people/new

app/helpers/better_together/application_helper.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,5 +204,25 @@ def main_app_url_helper?(method)
204204
def better_together_url_helper?(method)
205205
method.to_s.end_with?('_path', '_url') && BetterTogether::Engine.routes.url_helpers.respond_to?(method)
206206
end
207+
208+
# Returns the appropriate icon and color for an event based on the person's relationship to it
209+
def event_relationship_icon(person, event) # rubocop:todo Metrics/MethodLength
210+
relationship = person.event_relationship_for(event)
211+
212+
case relationship
213+
when :created
214+
{ icon: 'fas fa-user-edit', color: '#28a745',
215+
tooltip: t('better_together.events.relationship.created', default: 'Created by you') }
216+
when :going
217+
{ icon: 'fas fa-check-circle', color: '#007bff',
218+
tooltip: t('better_together.events.relationship.going', default: 'You\'re going') }
219+
when :interested
220+
{ icon: 'fas fa-heart', color: '#e91e63',
221+
tooltip: t('better_together.events.relationship.interested', default: 'You\'re interested') }
222+
else
223+
{ icon: 'fas fa-circle', color: '#6c757d',
224+
tooltip: t('better_together.events.relationship.calendar', default: 'Calendar event') }
225+
end
226+
end
207227
end
208228
end

app/helpers/better_together/badges_helper.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ def categories_badge(entity, rounded: true, style: 'info')
1212
)
1313
end
1414

15+
# Get the translated display value for a privacy setting
16+
def privacy_display_value(entity)
17+
return '' unless entity.respond_to?(:privacy) && entity.privacy.present?
18+
19+
privacy_key = entity.privacy.to_s.downcase
20+
t("attributes.privacy_list.#{privacy_key}", default: entity.privacy.humanize.capitalize)
21+
end
22+
1523
# Render a privacy badge for an entity.
1624
# By default, map known privacy values to sensible Bootstrap context classes.
1725
# Pass an explicit `style:` to force a fixed Bootstrap style instead of using the mapping.
@@ -29,7 +37,7 @@ def privacy_badge(entity, rounded: true, style: nil)
2937

3038
chosen_style = style || privacy_style_map[privacy_key] || 'primary'
3139

32-
create_badge(entity.privacy.humanize.capitalize, rounded: rounded, style: chosen_style)
40+
create_badge(privacy_display_value(entity), rounded: rounded, style: chosen_style)
3341
end
3442

3543
# Return the mapped bootstrap-style for an entity's privacy. Useful for wiring

0 commit comments

Comments
 (0)