diff --git a/.github/instructions/view-helpers.instructions.md b/.github/instructions/view-helpers.instructions.md index f01a8dcc7..e842abd5a 100644 --- a/.github/instructions/view-helpers.instructions.md +++ b/.github/instructions/view-helpers.instructions.md @@ -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 diff --git a/.rubocop.yml b/.rubocop.yml index 30be7cd55..ca040d080 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -13,6 +13,8 @@ plugins: - rubocop-rspec_rails - rubocop-capybara - rubocop-factory_bot +RSpec/MultipleExpectations: + Enabled: false Style/StringLiterals: Exclude: - 'db/migrate/*' diff --git a/app/assets/javascripts/better_together/controllers/event_datetime_controller.js b/app/assets/javascripts/better_together/controllers/event_datetime_controller.js new file mode 100644 index 000000000..32be1f825 --- /dev/null +++ b/app/assets/javascripts/better_together/controllers/event_datetime_controller.js @@ -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) + } +} diff --git a/app/controllers/better_together/agreements_controller.rb b/app/controllers/better_together/agreements_controller.rb index aa79a7c99..6b079b023 100644 --- a/app/controllers/better_together/agreements_controller.rb +++ b/app/controllers/better_together/agreements_controller.rb @@ -9,7 +9,7 @@ class AgreementsController < FriendlyResourceController # return only the fragment wrapped in the expected ... # 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 @@ -17,10 +17,14 @@ def show @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 diff --git a/app/controllers/better_together/events_controller.rb b/app/controllers/better_together/events_controller.rb index 16a9cc834..440c19e6d 100644 --- a/app/controllers/better_together/events_controller.rb +++ b/app/controllers/better_together/events_controller.rb @@ -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! @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/app/controllers/better_together/people_controller.rb b/app/controllers/better_together/people_controller.rb index 74d4d1905..19878afd9 100644 --- a/app/controllers/better_together/people_controller.rb +++ b/app/controllers/better_together/people_controller.rb @@ -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 diff --git a/app/helpers/better_together/application_helper.rb b/app/helpers/better_together/application_helper.rb index 7470d9d88..44b4b03bd 100644 --- a/app/helpers/better_together/application_helper.rb +++ b/app/helpers/better_together/application_helper.rb @@ -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 diff --git a/app/helpers/better_together/badges_helper.rb b/app/helpers/better_together/badges_helper.rb index 68e8edee3..bb6dd0e03 100644 --- a/app/helpers/better_together/badges_helper.rb +++ b/app/helpers/better_together/badges_helper.rb @@ -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. @@ -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 diff --git a/app/helpers/better_together/events_helper.rb b/app/helpers/better_together/events_helper.rb index 88070da7e..48fbf63d6 100644 --- a/app/helpers/better_together/events_helper.rb +++ b/app/helpers/better_together/events_helper.rb @@ -10,5 +10,72 @@ def visible_event_hosts(event) event.event_hosts.map { |eh| eh.host if policy(eh.host).show? }.compact end + + # Intelligently displays event time based on duration and date span + # + # Rules: + # - Under 1 hour: "Sept 4, 2:00 PM (30 minutes)" or "Sept 4, 2025 2:00 PM (30 minutes)" if not current year + # - 1-5 hours (same day): "Sept 4, 2:00 PM (3 hours)" or "Sept 4, 2025 2:00 PM (3 hours)" if not current year + # - Same day, over 5 hours: "Sept 4, 2:00 PM - 8:00 PM" or "Sept 4, 2025 2:00 PM - 8:00 PM" if not current year + # rubocop:todo Layout/LineLength + # - Different days: "Sept 4, 2:00 PM - Sept 5, 10:00 AM" or "Sept 4, 2025 2:00 PM - Sept 5, 2025 10:00 AM" if not current year + # rubocop:enable Layout/LineLength + # + # @param event [Event] The event object with starts_at and ends_at + # @return [String] Formatted time display + # rubocop:todo Metrics/PerceivedComplexity + # rubocop:todo Metrics/MethodLength + # rubocop:todo Metrics/AbcSize + def display_event_time(event) # rubocop:todo Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity + return '' unless event&.starts_at + + start_time = event.starts_at + end_time = event.ends_at + current_year = Time.current.year + + # Determine format based on whether year differs from current + start_format = start_time.year == current_year ? :event_date_time : :event_date_time_with_year + time_only_format = :time_only + time_only_with_year_format = :time_only_with_year + + # No end time + return l(start_time, format: start_format) unless end_time + + duration_minutes = ((end_time - start_time) / 60).round + duration_hours = duration_minutes / 60.0 + same_day = start_time.to_date == end_time.to_date + + if duration_minutes < 60 + # Under 1 hour: show minutes + "#{l(start_time, format: start_format)} (#{duration_minutes} #{t('better_together.events.time.minutes')})" + elsif duration_hours <= 5 && same_day + # 1-5 hours, same day: show hours + # rubocop:todo Layout/LineLength + hours_text = duration_hours == 1 ? t('better_together.events.time.hour') : t('better_together.events.time.hours') + # rubocop:enable Layout/LineLength + "#{l(start_time, format: start_format)} (#{duration_hours.to_i} #{hours_text})" + elsif same_day + # Same day, over 5 hours: show start and end times + # If start and end are different years, show year for both + end_time_format = if start_time.year == end_time.year + current_year == end_time.year ? time_only_format : time_only_with_year_format + else + time_only_with_year_format + end + "#{l(start_time, format: start_format)} - #{l(end_time, format: end_time_format)}" + else + # Different days: show full dates for both + # If start and end are different years, show year for both + end_format = if start_time.year == end_time.year + current_year == end_time.year ? :event_date_time : :event_date_time_with_year + else + :event_date_time_with_year + end + "#{l(start_time, format: start_format)} - #{l(end_time, format: end_format)}" + end + end + # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/MethodLength + # rubocop:enable Metrics/PerceivedComplexity end end diff --git a/app/javascript/controllers/better_together/event_datetime_controller.js b/app/javascript/controllers/better_together/event_datetime_controller.js new file mode 100644 index 000000000..32be1f825 --- /dev/null +++ b/app/javascript/controllers/better_together/event_datetime_controller.js @@ -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) + } +} diff --git a/app/javascript/controllers/better_together/event_hover_card_controller.js b/app/javascript/controllers/better_together/event_hover_card_controller.js new file mode 100644 index 000000000..0c1d385d1 --- /dev/null +++ b/app/javascript/controllers/better_together/event_hover_card_controller.js @@ -0,0 +1,271 @@ +import { Controller } from "@hotwired/stimulus" + +// Connects to data-controller="better_together--event-hover-card" +export default class extends Controller { + static values = { + eventId: String, + eventUrl: String + } + + connect() { + this.popover = null + this.isPopoverVisible = false + this.isNavigating = false + this.showTimeout = null + this.hideTimeout = null + this.eventCardContent = null + this.contentLoaded = false + + // Pre-fetch the event card content immediately + this.prefetchEventCard() + this.setupPopover() + } + + disconnect() { + this.cleanupPopover() + } + + async setupPopover() { + // Setup popover with initial loading content + this.popover = new bootstrap.Popover(this.element, { + content: this.getPopoverContent(), + html: true, + placement: 'auto', + fallbackPlacements: ['top', 'bottom', 'right', 'left'], + trigger: 'manual', // Use manual trigger for better control + delay: { show: 0, hide: 100 }, // No delay for show since content is pre-fetched + customClass: 'event-hover-card-popover', + sanitize: false, + container: 'body', // Render in body to avoid positioning issues + boundary: 'viewport', + offset: [0, 8] // Add some offset from the trigger element + }) + + // Setup manual hover events + this.setupHoverEvents() + } + + getPopoverContent() { + if (this.contentLoaded && this.eventCardContent) { + return this.eventCardContent + } else { + return '
Loading...
Loading event details...
' + } + } + + async prefetchEventCard() { + const requestUrl = `${this.eventUrlValue}?format=card` + + try { + const response = await fetch(`${this.eventUrlValue}?format=card`, { + headers: { + 'Accept': 'text/html', + 'X-Requested-With': 'XMLHttpRequest', + 'X-Card-Request': 'true' + } + }) + + if (response.ok) { + const cardHtml = await response.text() + this.eventCardContent = cardHtml + this.contentLoaded = true + + // Update popover content if it exists and is already shown + if (this.popover && this.isPopoverVisible) { + this.updatePopoverContent(cardHtml) + } + } else { + this.eventCardContent = this.generateFallbackContent() + this.contentLoaded = true + } + } catch (error) { + console.error('Error pre-fetching event card:', error) + this.eventCardContent = this.generateFallbackContent() + this.contentLoaded = true + } + } + + setupHoverEvents() { + const showPopover = () => { + // Don't show popover if we're navigating + if (this.isNavigating) return + + clearTimeout(this.hideTimeout) + this.showTimeout = setTimeout(() => { + if (!this.isNavigating) { + this.isPopoverVisible = true + + // Update content if it's been loaded since popover creation + if (this.contentLoaded) { + this.popover.setContent({ + '.popover-body': this.eventCardContent + }) + } + + this.popover.show() + } + }, 300) // Reduced delay since content is pre-fetched + } + + const hidePopover = () => { + clearTimeout(this.showTimeout) + + // Don't hide if we're navigating (cleanup will handle it) + if (this.isNavigating) return + + this.hideTimeout = setTimeout(() => { + if (!this.isNavigating) { + this.isPopoverVisible = false + this.popover.hide() + } + }, 100) + } + + // Show on hover + this.element.addEventListener('mouseenter', showPopover) + this.element.addEventListener('focus', showPopover) + + // Hide when leaving the trigger element + this.element.addEventListener('mouseleave', hidePopover) + this.element.addEventListener('blur', hidePopover) + + // Keep popover open when hovering over it + this.element.addEventListener('shown.bs.popover', () => { + const popoverElement = document.querySelector('.event-hover-card-popover') + if (popoverElement) { + popoverElement.addEventListener('mouseenter', () => { + clearTimeout(this.hideTimeout) + }) + popoverElement.addEventListener('mouseleave', hidePopover) + + // Intercept link clicks within the popover + this.setupPopoverLinkInterception(popoverElement) + } + }) + + // Handle popover hiding + this.element.addEventListener('hidden.bs.popover', () => { + this.isPopoverVisible = false + }) + } + + setupPopoverLinkInterception(popoverElement) { + // Find all links within the popover + const links = popoverElement.querySelectorAll('a[href]') + + links.forEach(link => { + link.addEventListener('click', (event) => { + // Set a flag to prevent any hover events from interfering + this.isNavigating = true + + // Hide the popover gracefully without disposing immediately + if (this.popover) { + this.popover.hide() + } + + // Schedule cleanup after Bootstrap's hide animation completes + setTimeout(() => { + this.safeCleanup() + }, 200) + + // Note: We don't preventDefault() here to allow normal navigation + }) + }) + } + + safeCleanup() { + // Clear all timeouts + if (this.showTimeout) clearTimeout(this.showTimeout) + if (this.hideTimeout) clearTimeout(this.hideTimeout) + + // Reset state + this.isPopoverVisible = false + this.isNavigating = false + + // Only dispose if popover still exists and is not in transition + if (this.popover) { + try { + this.popover.dispose() + this.popover = null + } catch (error) { + console.warn('Error disposing popover:', error) + this.popover = null + } + } + + // Clean up any remaining popover DOM elements + const remainingPopovers = document.querySelectorAll('.event-hover-card-popover') + remainingPopovers.forEach(popover => { + try { + popover.remove() + } catch (error) { + console.warn('Error removing popover element:', error) + } + }) + } + + cleanupPopover() { + // Clear all timeouts + if (this.showTimeout) clearTimeout(this.showTimeout) + if (this.hideTimeout) clearTimeout(this.hideTimeout) + + // Reset state + this.isPopoverVisible = false + + // Hide and dispose popover safely + if (this.popover) { + try { + this.popover.hide() + // Delay disposal to allow hide animation to complete + setTimeout(() => { + if (this.popover) { + try { + this.popover.dispose() + this.popover = null + } catch (error) { + console.warn('Error disposing popover:', error) + this.popover = null + } + } + }, 150) + } catch (error) { + console.warn('Error hiding popover:', error) + this.popover = null + } + } + + // Clean up any remaining popover DOM elements + setTimeout(() => { + const remainingPopovers = document.querySelectorAll('.event-hover-card-popover') + remainingPopovers.forEach(popover => { + try { + popover.remove() + } catch (error) { + console.warn('Error removing popover element:', error) + } + }) + }, 200) + } + + updatePopoverContent(content) { + if (this.popover) { + this.popover.setContent({ + '.popover-body': content + }) + } + } + + generateFallbackContent() { + return ` +
+
+ +

Unable to load event details

+ + View Event + +
+
+ ` + } +} diff --git a/app/javascript/controllers/better_together/tabs_controller.js b/app/javascript/controllers/better_together/tabs_controller.js index 141ed57a2..ecff79bcf 100644 --- a/app/javascript/controllers/better_together/tabs_controller.js +++ b/app/javascript/controllers/better_together/tabs_controller.js @@ -6,6 +6,10 @@ export default class extends Controller { static targets = ["tab"]; connect() { + // Get all available tabs (both via Stimulus targets and manual selection) + this.allTabs = this.tabTargets.length > 0 ? this.tabTargets : + Array.from(this.element.querySelectorAll('[data-bs-toggle="tab"]')); + this.activateTabFromHash(); this.setupTabChangeListener(); } @@ -13,49 +17,34 @@ export default class extends Controller { activateTabFromHash() { const hash = window.location.hash; if (hash) { - let selectedTab = this.element.querySelector(`[data-bs-target="${hash}"]`); - while (selectedTab) { - // Skip tabs inside the localized-fields class - if (selectedTab.closest('.localized-fields')) break; - - const tabTarget = this.element.querySelector(`${selectedTab.dataset.bsTarget}`); - const tabPanes = this.element.querySelectorAll('.nav-tab-pane'); - - this.tabTargets.forEach((tab) => { - tab.classList.remove('active'); - }); - selectedTab.classList.add('active'); - - tabPanes.forEach((pane) => { - pane.classList.remove('active'); - pane.classList.remove('show'); - }); - - if (tabTarget) { - tabTarget.classList.add('active'); - tabTarget.classList.add('show'); - } - - // Check if the selected tab is nested and activate its parent tab - const parentTabPane = selectedTab.closest('.nav-tab-pane'); - if (parentTabPane) { - const parentTab = this.element.querySelector(`[data-bs-target="#${parentTabPane.id}"]`); - selectedTab = parentTab; // Move up to the parent tab for the next iteration - } else { - selectedTab = null; // Exit the loop if no parent tab exists - } + // Look for tabs that target this hash with either href or data-bs-target + let selectedTab = this.element.querySelector(`[href="${hash}"]`) || + this.element.querySelector(`[data-bs-target="${hash}"]`); + + if (selectedTab && !selectedTab.closest('.localized-fields')) { + console.log('Activating tab from hash:', hash, selectedTab); + + // Let Bootstrap handle the tab activation + const tabInstance = new bootstrap.Tab(selectedTab); + tabInstance.show(); } } } setupTabChangeListener() { - this.tabTargets.forEach((link) => { + // Use the unified collection of all available tabs + const tabsToSetup = this.allTabs || []; + + tabsToSetup.forEach((link) => { if (link.closest('.localized-fields')) return; - link.addEventListener("shown.bs.tab", (event) => { - const targetHash = event.target.getAttribute("data-bs-target"); - if (targetHash) { - history.pushState({}, "", targetHash); // Add the hash to the address bar + // Primary: Listen for click events + link.addEventListener("click", (event) => { + const targetHash = event.target.getAttribute("href") || event.target.getAttribute("data-bs-target"); + + if (targetHash && targetHash.startsWith('#')) { + // Update immediately - no delay needed + history.pushState({}, "", targetHash); } }); }); diff --git a/app/models/better_together/event.rb b/app/models/better_together/event.rb index da7f6673e..b4c080793 100644 --- a/app/models/better_together/event.rb +++ b/app/models/better_together/event.rb @@ -35,12 +35,17 @@ class Event < ApplicationRecord translates :name translates :description, backend: :action_text + slugged :name + validates :name, presence: true validates :registration_url, format: { with: URI::DEFAULT_PARSER.make_regexp(%w[http https]) }, allow_blank: true, allow_nil: true + validates :duration_minutes, presence: true, numericality: { greater_than: 0 }, if: :starts_at? validate :ends_at_after_starts_at before_validation :set_host + before_validation :set_default_duration + before_validation :sync_time_duration_relationship accepts_nested_attributes_for :event_hosts, reject_if: :all_blank @@ -66,7 +71,7 @@ class Event < ApplicationRecord def self.permitted_attributes(id: false, destroy: false) super + %i[ - starts_at ends_at registration_url + starts_at ends_at duration_minutes registration_url ] + [ { location_attributes: BetterTogether::Geography::LocatableLocation.permitted_attributes(id: true, @@ -133,6 +138,14 @@ def significant_changes_for_notifications changes_to_check.keys & significant_attrs end + def start_time + starts_at + end + + def end_time + ends_at + end + # Check if event has location def location? location.present? @@ -168,6 +181,40 @@ def duration_in_hours private + # Set default duration if not set and start time is present + def set_default_duration + return unless starts_at.present? + return if duration_minutes.present? + + self.duration_minutes = 30 # Default to 30 minutes + end + + # Synchronize the relationship between start time, end time, and duration + def sync_time_duration_relationship # rubocop:todo Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity + return unless starts_at.present? + + if starts_at_changed? && !ends_at_changed? && duration_minutes.present? + # Start time changed, update end time based on duration + update_end_time_from_duration + elsif ends_at_changed? && !starts_at_changed? && ends_at.present? + # End time changed, update duration and validate end time is after start time + if ends_at <= starts_at + errors.add(:ends_at, 'must be after start time') + return + end + self.duration_minutes = ((ends_at - starts_at) / 60.0).round + elsif duration_minutes_changed? && !starts_at_changed? && !ends_at_changed? # rubocop:todo Lint/DuplicateBranch + # Duration changed, update end time + update_end_time_from_duration + end + end + + def update_end_time_from_duration + return unless starts_at.present? && duration_minutes.present? + + self.ends_at = starts_at + duration_minutes.minutes + end + # Send update notifications def send_update_notifications changes = significant_changes_for_notifications diff --git a/app/models/better_together/event_attendance.rb b/app/models/better_together/event_attendance.rb index 4f2f65600..da51f4b5d 100644 --- a/app/models/better_together/event_attendance.rb +++ b/app/models/better_together/event_attendance.rb @@ -13,5 +13,51 @@ class EventAttendance < ApplicationRecord validates :status, inclusion: { in: STATUS.values } validates :event_id, uniqueness: { scope: :person_id } + validate :event_must_be_scheduled + + after_save :manage_calendar_entry + after_destroy :remove_calendar_entry + + private + + def event_must_be_scheduled + return unless event + + return if event.scheduled? + + errors.add(:event, 'must be scheduled to allow RSVPs') + end + + def manage_calendar_entry + return unless saved_change_to_status? || saved_change_to_id? + + if status == 'going' + create_calendar_entry + else + remove_calendar_entry + end + end + + def create_calendar_entry + return if calendar_entry_exists? + + person.primary_calendar.calendar_entries.create!( + event: event, + starts_at: event.starts_at, + ends_at: event.ends_at, + duration_minutes: event.duration_minutes + ) + rescue ActiveRecord::RecordInvalid => e + Rails.logger.warn "Failed to create calendar entry for attendance #{id}: #{e.message}" + end + + def remove_calendar_entry + calendar_entry = person.primary_calendar.calendar_entries.find_by(event: event) + calendar_entry&.destroy + end + + def calendar_entry_exists? + person.primary_calendar.calendar_entries.exists?(event: event) + end end end diff --git a/app/models/better_together/person.rb b/app/models/better_together/person.rb index d2ecdd31e..ac6296de1 100644 --- a/app/models/better_together/person.rb +++ b/app/models/better_together/person.rb @@ -45,6 +45,9 @@ def self.primary_community_delegation_attrs has_many :agreement_participants, class_name: 'BetterTogether::AgreementParticipant', dependent: :destroy has_many :agreements, through: :agreement_participants + has_many :calendars, foreign_key: :creator_id, class_name: 'BetterTogether::Calendar', dependent: :destroy + has_many :event_attendances, class_name: 'BetterTogether::EventAttendance', dependent: :destroy + has_one :user_identification, lambda { where( @@ -67,7 +70,6 @@ def self.primary_community_delegation_attrs joinable_type: 'platform' slugged :identifier, use: %i[slugged mobility], dependent: :delete_all - store_attributes :preferences do locale String, default: I18n.default_locale.to_s time_zone String, default: ENV.fetch('APP_TIME_ZONE', 'Newfoundland') @@ -142,12 +144,88 @@ def primary_community_extra_attrs { protected: true } end + def primary_calendar + @primary_calendar ||= calendars.find_or_create_by( + identifier: "#{identifier}-personal-calendar", + community: + ) do |calendar| + calendar.name = I18n.t('better_together.calendars.personal_calendar_name', name: name) + calendar.privacy = 'private' + calendar.protected = true + end + end + def after_record_created return unless community community.update!(creator_id: id) end + # Returns all events relevant to this person's calendar view + # Combines events they're going to, created, and interested in + def all_calendar_events # rubocop:todo Metrics/AbcSize, Metrics/MethodLength + @all_calendar_events ||= begin + # Build a single query to get all relevant events with proper includes + event_ids = Set.new + + # Get event IDs from calendar entries (going events) + calendar_event_ids = primary_calendar.calendar_entries.pluck(:event_id) + event_ids.merge(calendar_event_ids) + + # Get event IDs from attendances (interested events) + attendance_event_ids = event_attendances.pluck(:event_id) + event_ids.merge(attendance_event_ids) + + # Get event IDs from created events + created_event_ids = Event.where(creator_id: id).pluck(:id) + event_ids.merge(created_event_ids) + + # Single query to fetch all events with necessary includes + if event_ids.any? + Event.includes(:string_translations, :text_translations) + .where(id: event_ids.to_a) + .to_a + else + [] + end + end + end + + # Determines the relationship type for an event + # Returns: :going, :created, :interested, or :calendar + def event_relationship_for(event) + # Check if they created it first (highest priority) + return :created if event.creator_id == id + + # Use memoized attendances to avoid N+1 queries + @_event_attendances_by_event_id ||= event_attendances.index_by(&:event_id) + attendance = @_event_attendances_by_event_id[event.id] + + return attendance.status.to_sym if attendance + + # Check if it's in their calendar (for events added directly to calendar) + @_calendar_event_ids ||= Set.new(primary_calendar.calendar_entries.pluck(:event_id)) + return :going if @_calendar_event_ids.include?(event.id) + + :calendar # Default for calendar events + end + + # Preloads associations needed for calendar display to avoid N+1 queries + def preload_calendar_associations! + # Preload event attendances + event_attendances.includes(:event).load + + # Preload calendar entries + primary_calendar.calendar_entries.includes(:event).load + + # Reset memoized variables + @all_calendar_events = nil + @_event_attendances_by_event_id = nil + @_calendar_event_ids = nil + + self + end + include ::BetterTogether::RemoveableAttachment end end diff --git a/app/policies/better_together/calendar_policy.rb b/app/policies/better_together/calendar_policy.rb index aaed43f83..ab917a417 100644 --- a/app/policies/better_together/calendar_policy.rb +++ b/app/policies/better_together/calendar_policy.rb @@ -8,17 +8,30 @@ def index? end def show? - user.present? + user.present? && (can_view_calendar? || permitted_to?('manage_platform')) end def update? - user.present? && (record.creator == agent or permitted_to?('manage_platform')) + user.present? && (record.creator == agent || permitted_to?('manage_platform')) end def create? user.present? && permitted_to?('manage_platform') end + private + + def can_view_calendar? + return true if record.privacy_public? + return true if record.creator == agent + + false + end + + def same_community? + agent&.community_id == record.community_id + end + # Filtering and sorting for calendars according to permissions and context class Scope < ApplicationPolicy::Scope end diff --git a/app/policies/better_together/event_attendance_policy.rb b/app/policies/better_together/event_attendance_policy.rb index 24d7e4a89..c382d1872 100644 --- a/app/policies/better_together/event_attendance_policy.rb +++ b/app/policies/better_together/event_attendance_policy.rb @@ -4,11 +4,11 @@ module BetterTogether # Access control for event attendance (RSVPs) class EventAttendancePolicy < ApplicationPolicy def create? - user.present? + user.present? && event_allows_rsvp? end def update? - user.present? && record.person_id == agent&.id + user.present? && record.person_id == agent&.id && event_allows_rsvp? end alias rsvp_interested? update? @@ -17,5 +17,15 @@ def update? def destroy? update? end + + private + + def event_allows_rsvp? + event = record&.event || record + return false unless event + + # Don't allow RSVP for draft events (no start date) + event.scheduled? + end end end diff --git a/app/views/better_together/agreements/_modal_content.html.erb b/app/views/better_together/agreements/_modal_content.html.erb new file mode 100644 index 000000000..a59252448 --- /dev/null +++ b/app/views/better_together/agreements/_modal_content.html.erb @@ -0,0 +1,20 @@ + + <% if @page.present? %> + <%= render template: 'better_together/pages/show' %> + <% else %> +
+
+

<%= @agreement.title %>

+
+
+

Description: <%= @agreement.description %>

+

Creator: <%= @agreement.creator %>

+

Privacy: <%= privacy_display_value(@agreement) %>

+ +
+

<%= t('better_together.agreements.terms', default: 'Terms') %>

+ <%= render partial: 'better_together/agreement_terms/terms_list', locals: { agreement: @agreement } %> +
+
+ <% end %> +
diff --git a/app/views/better_together/agreements/show.html.erb b/app/views/better_together/agreements/show.html.erb index 390cf4024..c9c48f32d 100644 --- a/app/views/better_together/agreements/show.html.erb +++ b/app/views/better_together/agreements/show.html.erb @@ -11,7 +11,7 @@

Description: <%= @agreement.description %>

Creator: <%= @agreement.creator %>

-

Privacy: <%= @agreement.privacy %>

+

Privacy: <%= privacy_display_value(@agreement) %>


<%= t('better_together.agreements.terms', default: 'Terms') %>

diff --git a/app/views/better_together/calendars/_calendar.html.erb b/app/views/better_together/calendars/_calendar.html.erb index f28802fd5..68a34ff18 100644 --- a/app/views/better_together/calendars/_calendar.html.erb +++ b/app/views/better_together/calendars/_calendar.html.erb @@ -15,7 +15,7 @@ <%= calendar.description if calendar.description.present? %>

- <%= calendar.privacy %> + <%= privacy_display_value(calendar) %>

diff --git a/app/views/better_together/calls_for_interest/show.html.erb b/app/views/better_together/calls_for_interest/show.html.erb index c16d6b5b5..2c82f5196 100644 --- a/app/views/better_together/calls_for_interest/show.html.erb +++ b/app/views/better_together/calls_for_interest/show.html.erb @@ -57,7 +57,7 @@

<% end %>
- <%= @call_for_interest.privacy.humanize %> + <%= privacy_display_value(@call_for_interest) %>

diff --git a/app/views/better_together/checklists/_checklist.html.erb b/app/views/better_together/checklists/_checklist.html.erb index 01a1f4406..31321ae40 100644 --- a/app/views/better_together/checklists/_checklist.html.erb +++ b/app/views/better_together/checklists/_checklist.html.erb @@ -13,7 +13,7 @@ back_to_list_path: better_together.checklists_path(locale: I18n.locale), edit_path: (policy(checklist).update? ? better_together.edit_checklist_path(checklist, locale: I18n.locale) : nil), destroy_path: (policy(checklist).destroy? ? better_together.checklist_path(checklist, locale: I18n.locale) : nil) do %> - <%= checklist.privacy %> + <%= privacy_display_value(checklist) %> <% end %> diff --git a/app/views/better_together/communities/_row.html.erb b/app/views/better_together/communities/_row.html.erb index 0e6c7c130..ee889c727 100644 --- a/app/views/better_together/communities/_row.html.erb +++ b/app/views/better_together/communities/_row.html.erb @@ -4,7 +4,7 @@ <%= community.description_html.to_plain_text.truncate(100) %> <%= community.slug %> <%= community.class.model_name %> - <%= community.privacy %> + <%= privacy_display_value(community) %> <%= community.protected ? t('globals.yes') : t('globals.no') %> <%= community.host ? t('globals.yes') : t('globals.no') %> diff --git a/app/views/better_together/events/_event.html.erb b/app/views/better_together/events/_event.html.erb index 2b78b4e9e..3a7d4d9b4 100644 --- a/app/views/better_together/events/_event.html.erb +++ b/app/views/better_together/events/_event.html.erb @@ -11,7 +11,7 @@ <% end %> <% if event.starts_at.present? %>

- <%= l(event.starts_at, format: :short) %> + <%= display_event_time event %>
<% end %>
diff --git a/app/views/better_together/events/_event_datetime_fields.html.erb b/app/views/better_together/events/_event_datetime_fields.html.erb new file mode 100644 index 000000000..cf4c0114f --- /dev/null +++ b/app/views/better_together/events/_event_datetime_fields.html.erb @@ -0,0 +1,70 @@ + +<%# Event Date/Time Fields Partial + + Usage: + render 'event_datetime_fields', form: form, event: event + + Parameters: + - form: Rails form builder object + - event: Event model instance for validation errors and current values +%> +
+ +
+ <%= required_label form, :starts_at, class: 'form-label' %> + <%= form.datetime_field :starts_at, + include_seconds: false, + class: 'form-control', + data: { + action: 'change->better_together--event-datetime#updateEndTime' + }, + 'data-better_together--event-datetime-target' => 'startTime' %> + <% if event.errors[:starts_at].any? %> +
+ <%= event.errors[:starts_at].join(", ") %> +
+ <% end %> + <%= t('better_together.events.hints.starts_at') %> +
+ + +
+ <%= form.label :ends_at, t('better_together.events.labels.ends_at'), class: 'form-label' %> + <%= form.datetime_field :ends_at, + include_seconds: false, + class: 'form-control', + data: { + action: 'change->better_together--event-datetime#updateDuration' + }, + 'data-better_together--event-datetime-target' => 'endTime' %> + <% if event.errors[:ends_at].any? %> +
+ <%= event.errors[:ends_at].join(", ") %> +
+ <% end %> + <%= t('better_together.events.hints.ends_at') %> +
+ + +
+ <%= form.label :duration_minutes, t('better_together.events.labels.duration_minutes'), class: 'form-label' %> +
+ <%= form.number_field :duration_minutes, + step: 5, + min: 5, + class: 'form-control', + value: event.duration_minutes || 30, + data: { + action: 'change->better_together--event-datetime#updateEndTimeFromDuration' + }, + 'data-better_together--event-datetime-target' => 'duration' %> + <%= t('better_together.events.units.minutes') %> +
+ <% if event.errors[:duration_minutes].any? %> +
+ <%= event.errors[:duration_minutes].join(", ") %> +
+ <% end %> + <%= t('better_together.events.hints.duration_minutes') %> +
+
diff --git a/app/views/better_together/events/_form.html.erb b/app/views/better_together/events/_form.html.erb index d5015885d..2d72b6d8f 100644 --- a/app/views/better_together/events/_form.html.erb +++ b/app/views/better_together/events/_form.html.erb @@ -100,30 +100,7 @@