Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
a4966af
Implement RSVP restrictions for draft events and enhance user feedback
rsmithlal Sep 4, 2025
6ec979f
Add event duration handling and display logic; enhance localization s…
rsmithlal Sep 5, 2025
0928ca4
Rubocop fixes
rsmithlal Sep 5, 2025
772b130
Rubocop fixes
rsmithlal Sep 5, 2025
c321ae4
Fix event time display by using the correct event variable in the tem…
rsmithlal Sep 5, 2025
d5336d5
Refactor event reminder job spec to simplify draft event creation and…
rsmithlal Sep 5, 2025
8507850
Change duration_minutes column type from decimal to integer in better…
rsmithlal Sep 5, 2025
a7c5ed6
Add privacy display helpers and update views for consistent privacy r…
rsmithlal Sep 5, 2025
7cf40e9
Rubocop fixes
rsmithlal Sep 5, 2025
3de8454
Add localization for 'no description available' in English, Spanish, …
rsmithlal Sep 5, 2025
784866a
Enhance event action buttons with icons for better user experience
rsmithlal Sep 5, 2025
3faa243
Add personal calendar feature with event management and localization …
rsmithlal Sep 5, 2025
1f30805
Implement event hover card feature with AJAX support and relationship…
rsmithlal Sep 5, 2025
ad0647e
Add Turbo Frame support for agreement modal rendering
rsmithlal Sep 5, 2025
4f615ab
Enhance RSVP functionality and optimize event loading
rsmithlal Sep 5, 2025
977748f
Refactor tab controller and improve localization for RSVP management
rsmithlal Sep 5, 2025
afc261b
Fix line break in Spanish locale for login required message
rsmithlal Sep 5, 2025
553de3e
Refactor calendar policy to remove community privacy check for calend…
rsmithlal Sep 6, 2025
e7e1e23
Refactor RSVP cancellation and update methods to handle 404 responses…
rsmithlal Sep 6, 2025
1640614
Rubocop fixes
rsmithlal Sep 6, 2025
02227ba
Enhance message sending spec to ensure message visibility in chat window
rsmithlal Sep 6, 2025
945de47
Rubocop fixes
rsmithlal Sep 6, 2025
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
14 changes: 14 additions & 0 deletions .github/instructions/view-helpers.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,20 @@ end
- `truncate`, `pluralize`, `excerpt`, `word_wrap`, `simple_format`.
- For rich text (Action Text), use `record.body` and `to_plain_text` for indexing/search.

### Privacy Display
- Use `privacy_display_value(entity)` for consistent, translated privacy level display across the application.
- This helper automatically looks up the proper translation from `attributes.privacy_list` and falls back to humanized values.
- Supports all privacy levels: `public`, `private`, `community`, `unlisted`.

```ruby
# Instead of: entity.privacy.humanize or entity.privacy.capitalize
<%= privacy_display_value(@event) %> # "Public" / "Público" / "Public"
<%= privacy_display_value(@community) %> # "Private" / "Privado" / "Privé"

# Works in badges too (automatically used)
<%= privacy_badge(@entity) %> # Uses privacy_display_value internally
```

---

## 4. Navigation & Link Helpers
Expand Down
2 changes: 2 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ plugins:
- rubocop-rspec_rails
- rubocop-capybara
- rubocop-factory_bot
RSpec/MultipleExpectations:
Enabled: false
Style/StringLiterals:
Exclude:
- 'db/migrate/*'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Event DateTime Controller
// Handles dynamic synchronization between start time, end time, and duration fields
//
// Behavior:
// - When start time changes: Updates end time based on current duration
// - When end time changes: Updates duration based on start/end time difference
// - When duration changes: Updates end time based on start time + duration
// - Validates minimum duration (5 minutes)
// - Defaults duration to 30 minutes when not set

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
static targets = ["startTime", "endTime", "duration"]

connect() {
// Set default duration if not already set
if (!this.durationTarget.value || this.durationTarget.value === "0") {
this.durationTarget.value = "30"
}

// Initialize end time if start time is set but end time is not
if (this.startTimeTarget.value && !this.endTimeTarget.value) {
this.updateEndTimeFromDuration()
}
}

// Called when start time changes
updateEndTime() {
if (!this.startTimeTarget.value) {
this.endTimeTarget.value = ""
return
}

// Use current duration or default to 30 minutes
const duration = this.getDurationInMinutes()
this.calculateEndTime(duration)
}

// Called when end time changes
updateDuration() {
if (!this.startTimeTarget.value || !this.endTimeTarget.value) {
return
}

const startTime = new Date(this.startTimeTarget.value)
const endTime = new Date(this.endTimeTarget.value)

// Validate end time is after start time
if (endTime <= startTime) {
this.showTemporaryError("End time must be after start time")
return
}

// Calculate duration in minutes
const diffInMs = endTime.getTime() - startTime.getTime()
const diffInMinutes = Math.round(diffInMs / (1000 * 60))

// Enforce minimum duration
if (diffInMinutes < 5) {
this.durationTarget.value = "5"
this.calculateEndTime(5)
} else {
this.durationTarget.value = diffInMinutes.toString()
}
}

// Called when duration changes
updateEndTimeFromDuration() {
if (!this.startTimeTarget.value) {
return
}

const duration = this.getDurationInMinutes()

// Enforce minimum duration
if (duration < 5) {
this.durationTarget.value = "5"
this.calculateEndTime(5)
} else {
this.calculateEndTime(duration)
}
}

// Helper methods
getDurationInMinutes() {
const duration = parseInt(this.durationTarget.value) || 30
return Math.max(duration, 5) // Minimum 5 minutes
}

calculateEndTime(durationMinutes) {
if (!this.startTimeTarget.value) return

const startTime = new Date(this.startTimeTarget.value)
const endTime = new Date(startTime.getTime() + (durationMinutes * 60 * 1000))

// Format for datetime-local input (YYYY-MM-DDTHH:MM)
const year = endTime.getFullYear()
const month = String(endTime.getMonth() + 1).padStart(2, '0')
const day = String(endTime.getDate()).padStart(2, '0')
const hours = String(endTime.getHours()).padStart(2, '0')
const minutes = String(endTime.getMinutes()).padStart(2, '0')

this.endTimeTarget.value = `${year}-${month}-${day}T${hours}:${minutes}`
}

showTemporaryError(message) {
// Create or update error message
let errorElement = this.element.querySelector('.datetime-sync-error')

if (!errorElement) {
errorElement = document.createElement('div')
errorElement.className = 'alert alert-warning datetime-sync-error mt-2'
errorElement.setAttribute('role', 'alert')
this.element.appendChild(errorElement)
}

errorElement.textContent = message

// Remove error after 3 seconds
setTimeout(() => {
if (errorElement && errorElement.parentNode) {
errorElement.parentNode.removeChild(errorElement)
}
}, 3000)
}
}
14 changes: 9 additions & 5 deletions app/controllers/better_together/agreements_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,22 @@ class AgreementsController < FriendlyResourceController
# return only the fragment wrapped in the expected <turbo-frame id="agreement_modal_frame">...</turbo-frame>
# so Turbo can swap it into the frame. For normal requests, fall back to the
# default rendering (with layout).
def show
def show # rubocop:todo Metrics/MethodLength
if @agreement.page
@page = @agreement.page
@content_blocks = @page.content_blocks
@layout = 'layouts/better_together/page'
@layout = @page.layout if @page.layout.present?
end

return unless turbo_frame_request?

content = render_to_string(action: :show, layout: false)
render html: view_context.turbo_frame_tag('agreement_modal_frame', content)
# Check if this is a Turbo Frame request
if request.headers['Turbo-Frame'].present?
Rails.logger.debug 'Rendering turbo frame response'
render partial: 'modal_content', layout: false
else
Rails.logger.debug 'Rendering normal response'
# Normal full-page rendering continues with the view
end
end

protected
Expand Down
102 changes: 95 additions & 7 deletions app/controllers/better_together/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

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

def show
# Handle AJAX requests for card format - only our specific hover card requests
card_request = request.headers['X-Card-Request'] == 'true' || request.headers['HTTP_X_CARD_REQUEST'] == 'true'

if request.xhr? && card_request
render partial: 'better_together/events/event', locals: { event: @event }, layout: false
return
end

super
end

Expand All @@ -35,10 +43,21 @@ def rsvp_going
rsvp_update('going')
end

def rsvp_cancel
@event = set_resource_instance
def rsvp_cancel # rubocop:disable Metrics/MethodLength
set_resource_instance
return if performed? # Exit early if 404 was already rendered

@event = @resource
authorize @event, :show?
attendance = BetterTogether::EventAttendance.find_by(event: @event, person: helpers.current_person)

# Ensure current_person exists
current_person = helpers.current_person
unless current_person
redirect_to @event, alert: t('better_together.events.login_required', default: 'Please log in to manage RSVPs.')
return
end

attendance = BetterTogether::EventAttendance.find_by(event: @event, person: current_person)
attendance&.destroy
redirect_to @event, notice: t('better_together.events.rsvp_cancelled', default: 'RSVP cancelled')
end
Expand Down Expand Up @@ -74,10 +93,30 @@ def resource_class

private

def rsvp_update(status)
@event = set_resource_instance
# rubocop:todo Metrics/MethodLength
def rsvp_update(status) # rubocop:todo Metrics/AbcSize, Metrics/MethodLength
set_resource_instance
return if performed? # Exit early if 404 was already rendered

@event = @resource
authorize @event, :show?
attendance = BetterTogether::EventAttendance.find_or_initialize_by(event: @event, person: helpers.current_person)

# Check if event allows RSVP
unless @event.scheduled?
redirect_to @event,
alert: t('better_together.events.rsvp_not_available',
default: 'RSVP is not available for this event.')
return
end

# Ensure current_person exists before creating attendance
current_person = helpers.current_person
unless current_person
redirect_to @event, alert: t('better_together.events.login_required', default: 'Please log in to RSVP.')
return
end

attendance = BetterTogether::EventAttendance.find_or_initialize_by(event: @event, person: current_person)
attendance.status = status
authorize attendance
if attendance.save
Expand All @@ -86,5 +125,54 @@ def rsvp_update(status)
redirect_to @event, alert: attendance.errors.full_messages.to_sentence
end
end
# rubocop:enable Metrics/MethodLength

# Override base controller method to add performance optimizations
def set_resource_instance
super

# Preload associations needed for event show page to avoid N+1 queries
preload_event_associations! unless json_request?
end

def json_request?
request.format.json?
end

# rubocop:todo Metrics/AbcSize
# rubocop:todo Metrics/MethodLength
def preload_event_associations! # rubocop:todo Metrics/CyclomaticComplexity, Metrics/AbcSize
return unless @event

# Preload categories and their translations to avoid N+1 queries
@event.categories.includes(:string_translations).load

# Preload event hosts and their associated models
@event.event_hosts.includes(:host).load

# Preload event attendances to avoid count queries in view
@event.event_attendances.includes(:person).load

# Preload current person's attendance for RSVP buttons
if current_person
@current_attendance = @event.event_attendances.find do |a|
a.person_id == current_person.id
end
end

# Preload translations for the event itself
@event.string_translations.load
@event.text_translations.load

# Preload cover image attachment to avoid attachment queries
@event.cover_image_attachment&.blob&.load if @event.cover_image.attached?

# Preload location if present
@event.location&.reload

self
end
# rubocop:enable Metrics/MethodLength
# rubocop:enable Metrics/AbcSize
end
end
3 changes: 3 additions & 0 deletions app/controllers/better_together/people_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ def show
:string_translations,
blocks: { background_image_file_attachment: :blob }
)

# Preload calendar associations to avoid N+1 queries
@person.preload_calendar_associations!
end

# GET /people/new
Expand Down
20 changes: 20 additions & 0 deletions app/helpers/better_together/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -204,5 +204,25 @@ def main_app_url_helper?(method)
def better_together_url_helper?(method)
method.to_s.end_with?('_path', '_url') && BetterTogether::Engine.routes.url_helpers.respond_to?(method)
end

# Returns the appropriate icon and color for an event based on the person's relationship to it
def event_relationship_icon(person, event) # rubocop:todo Metrics/MethodLength
relationship = person.event_relationship_for(event)

case relationship
when :created
{ icon: 'fas fa-user-edit', color: '#28a745',
tooltip: t('better_together.events.relationship.created', default: 'Created by you') }
when :going
{ icon: 'fas fa-check-circle', color: '#007bff',
tooltip: t('better_together.events.relationship.going', default: 'You\'re going') }
when :interested
{ icon: 'fas fa-heart', color: '#e91e63',
tooltip: t('better_together.events.relationship.interested', default: 'You\'re interested') }
else
{ icon: 'fas fa-circle', color: '#6c757d',
tooltip: t('better_together.events.relationship.calendar', default: 'Calendar event') }
end
end
end
end
10 changes: 9 additions & 1 deletion app/helpers/better_together/badges_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ def categories_badge(entity, rounded: true, style: 'info')
)
end

# Get the translated display value for a privacy setting
def privacy_display_value(entity)
return '' unless entity.respond_to?(:privacy) && entity.privacy.present?

privacy_key = entity.privacy.to_s.downcase
t("attributes.privacy_list.#{privacy_key}", default: entity.privacy.humanize.capitalize)
end

# Render a privacy badge for an entity.
# By default, map known privacy values to sensible Bootstrap context classes.
# Pass an explicit `style:` to force a fixed Bootstrap style instead of using the mapping.
Expand All @@ -29,7 +37,7 @@ def privacy_badge(entity, rounded: true, style: nil)

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

create_badge(entity.privacy.humanize.capitalize, rounded: rounded, style: chosen_style)
create_badge(privacy_display_value(entity), rounded: rounded, style: chosen_style)
end

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