diff --git a/Gemfile b/Gemfile index b1f0e946d..ff349d581 100644 --- a/Gemfile +++ b/Gemfile @@ -129,3 +129,5 @@ gem 'scout_apm' gem 'carrierwave-aws', '~> 1.6' gem 'sitemap_generator', '~> 7.0' + +gem "solid_cache", "~> 1.0" diff --git a/Gemfile.lock b/Gemfile.lock index ecabd5b13..6ed759712 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -553,6 +553,10 @@ GEM snaky_hash (2.0.6) hashie (>= 0.1.0, < 6) version_gem (>= 1.1.8, < 3) + solid_cache (1.0.10) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) sprockets (4.2.2) concurrent-ruby (~> 1.0) logger @@ -697,6 +701,7 @@ DEPENDENCIES simplecov simplecov-lcov sitemap_generator (~> 7.0) + solid_cache (~> 1.0) sprockets-rails stimulus-rails stripe @@ -916,6 +921,7 @@ CHECKSUMS sitemap_generator (7.0.1) sha256=cb436da1c6cb073261a63c27e714e27b8292fca1e17ad503dac1db6518b3d2a8 slop (3.6.0) sha256=76ccab03be66bfcab4838cdc07cab019cd3e192a3538266246749e79e4788803 snaky_hash (2.0.6) sha256=3663cae48cdef582b517025cf8a39d8789996eaf0b4ed89e2f0624836505654a + solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 sprockets (4.2.2) sha256=761e5a49f1c288704763f73139763564c845a8f856d52fba013458f8af1b59b1 sprockets-rails (3.5.2) sha256=a9e88e6ce9f8c912d349aa5401509165ec42326baf9e942a85de4b76dbc4119e ssrf_filter (1.5.0) sha256=e03dcdb9d1730d7f6710532a606b3543df2a448a0293ce04a2d995523c5a97f6 diff --git a/app/components/event_card_component.html.erb b/app/components/event_card_component.html.erb new file mode 100644 index 000000000..a591e3226 --- /dev/null +++ b/app/components/event_card_component.html.erb @@ -0,0 +1,75 @@ +<% cache @event.cache_key_with_version do %> +
+
+
+
+ <% if @event.chapter %> + + <%= link_to @event.chapter.name, @event.chapter.slug, class: "text-light text-decoration-none" %> + + <% end %> + <% if user_presenter %> + <% if user_presenter.attending?(@event) %> + + <%= link_to "Attending", @event.path, class: "text-light text-decoration-none" %> + + <% end %> + <% if user_presenter.organiser? && user_presenter.event_organiser?(@event) %> + + <%= link_to "Manage", @event.admin_path, class: "text-light text-decoration-none" %> + + <% end %> + <% end %> +
+
+

+ <%= link_to @event.to_s, @event.path %> + <% if @event.venue.present? %> + at + <%= link_to @event.venue.name, @event.venue.website %> + <% end %> +

+
+
+ +
+

+ + <%= @event.date %> +

+

+ + <%= @event.time %> +

+
+ +
+ <% if @event.organisers.any? %> +
+ <% @event.organisers.each do |organiser| %> + <%= image_tag(organiser.avatar(26), class: "rounded-circle", title: organiser.full_name, alt: organiser.full_name) %> + <% end %> +
+ <% end %> + + <% if @event.sponsors.any? %> +
+ <% @event.sponsors.each do |sponsor| %> + <%= link_to sponsor.website, class: "d-inline-block" do %> + <%= image_tag(sponsor.avatar.thumb.url, class: "sponsor-sm mx-1", alt: sponsor.name) %> + <% end %> + <% end %> +
+ <% end %> + + <% if @event.is_a?(MeetingPresenter) && @event.venue.present? %> +
+ <%= link_to @event.venue.website, class: "d-inline-block" do %> + <%= image_tag(@event.venue.avatar.url, class: "sponsor-sm", alt: @event.venue.name) %> + <% end %> +
+ <% end %> +
+
+
+<% end %> diff --git a/app/components/event_card_component.rb b/app/components/event_card_component.rb new file mode 100644 index 000000000..8bcbbad1b --- /dev/null +++ b/app/components/event_card_component.rb @@ -0,0 +1,11 @@ +class EventCardComponent < ViewComponent::Base + def initialize(event_card:, user: nil) + @event = event_card + @user = user + end + + # Wraps raw Member in MemberPresenter; double-wrapping a presenter is a no-op. + def user_presenter + @user && MemberPresenter.new(@user) + end +end diff --git a/app/views/chapter/show.html.haml b/app/views/chapter/show.html.haml index 0516c8f62..78f636a2a 100644 --- a/app/views/chapter/show.html.haml +++ b/app/views/chapter/show.html.haml @@ -29,13 +29,13 @@ %h2.mb-4= t('homepage.events.upcoming') - @upcoming_workshops.each do |date, workshops| %h3.h5= date - = render workshops + = render EventCardComponent.with_collection(workshops) - if @latest_workshops.any? .pt-4 %h2.mb-4 Past Events - @latest_workshops.each do |date, workshops| - = render workshops + = render EventCardComponent.with_collection(workshops) - if @recent_sponsors.any? .py-4.py-lg-5.bg-light diff --git a/app/views/dashboard/dashboard.html.haml b/app/views/dashboard/dashboard.html.haml index 89d766e17..618f5478f 100644 --- a/app/views/dashboard/dashboard.html.haml +++ b/app/views/dashboard/dashboard.html.haml @@ -32,4 +32,4 @@ There are no upcoming events announced for the chapters you are subscribed to. - @ordered_events.each do |date, workshops| %h4= date - = render workshops + = render EventCardComponent.with_collection(workshops, user: current_user) diff --git a/app/views/dashboard/show.html.haml b/app/views/dashboard/show.html.haml index 53c43ba8a..f52f2ed53 100644 --- a/app/views/dashboard/show.html.haml +++ b/app/views/dashboard/show.html.haml @@ -71,7 +71,7 @@ %h2.h3.mb-4= t('homepage.events.upcoming') - @upcoming_workshops.each do |date, workshops| %h3.h5= date - = render workshops + = render EventCardComponent.with_collection(workshops, user: current_user) - if @has_more_events = link_to 'Explore all events →', upcoming_events_path, class: 'btn btn-outline-primary mt-3' diff --git a/app/views/events/_event.html.haml b/app/views/events/_event.html.haml deleted file mode 100644 index bfd89a4e3..000000000 --- a/app/views/events/_event.html.haml +++ /dev/null @@ -1,40 +0,0 @@ -.card.mb-4{'data-test': 'event'} - .card-body - .d-md-flex.justify-content-md-between - .order-md-2 - - if event.chapter - %span.badge.bg-primary.mb-3.mb-md-0 - = link_to event.chapter.name, event.chapter.slug, class: 'text-light text-decoration-none' - - if @user - - if @user.attending?(event.__getobj__) - %span.badge.bg-success.mb-3.mb-md-0 - = link_to 'Attending', event.path, class: 'text-light text-decoration-none' - - if @user.organiser? && @user.event_organiser?(event) - %span.badge.bg-secondary.mb-3.mb-md-0 - = link_to 'Manage', event.admin_path, class: 'text-light text-decoration-none' - .order-md-1 - %h3.h5 - = link_to event.to_s, event.path - - if event.venue.present? - at - = link_to event.venue.name, event.venue.website - - .mt-3 - %p.mb-0 - %i.far.fa-calendar - = event.date - %p - %i.far.fa-clock - = event.time - - - if event.organisers.any? || event.sponsors.any? - .d-md-flex.justify-content-md-between.align-items-md-center - - if event.organisers.any? - .mb-3.mb-md-0 - - event.organisers.each do |organiser| - = image_tag(organiser.avatar(26), class: 'rounded-circle', title: organiser.full_name, alt: organiser.full_name) - - if event.sponsors.any? - .ms-auto - - event.sponsors.each do |sponsor| - = link_to sponsor.website, class: 'd-inline-block' do - = image_tag(sponsor.avatar.thumb.url, class: 'sponsor-sm mx-1', alt: sponsor.name) diff --git a/app/views/events/_events.html.haml b/app/views/events/_events.html.haml index 95a9a1dd0..b85ef458e 100644 --- a/app/views/events/_events.html.haml +++ b/app/views/events/_events.html.haml @@ -1,7 +1,7 @@ -- grouped_events.each do |date, workshops| +- grouped_events.each do |date, events| .row .col-12 %h3.h5= date .row .col-md-8 - = render workshops + = render EventCardComponent.with_collection(events) diff --git a/app/views/meetings/_meeting.html.haml b/app/views/meetings/_meeting.html.haml deleted file mode 100644 index fabcfefe5..000000000 --- a/app/views/meetings/_meeting.html.haml +++ /dev/null @@ -1,39 +0,0 @@ -.card.mb-4{'data-test': 'event'} - .card-body - .d-md-flex.justify-content-md-between - .order-md-2 - - if meeting.chapter - %span.badge.bg-primary.mb-3.mb-md-0 - = link_to meeting.chapter.name, meeting.chapter.slug, class: 'text-light text-decoration-none' - - if @user - - if @user.attending?(meeting) - %span.badge.bg-success.mb-3.mb-md-0 - = link_to 'Attending', meeting.path, class: 'text-light border-0' - - if @user.event_organiser?(meeting) - %span.badge.bg-secondary.mb-3.mb-md-0 - = link_to 'Manage', meeting.admin_path, class: 'text-light border-0' - .order-md-1 - %h3.h5 - = link_to meeting.name, meeting_path(meeting.slug) - - if meeting.venue.present? - at - = link_to meeting.venue.name, meeting.venue.website - - .mt-3 - %p.mb-0 - %i.far.fa-calendar - = meeting.date - %p - %i.far.fa-clock - = meeting.time - - - if meeting.organisers.any? || meeting.venue.present? - .d-md-flex.justify-content-md-between.align-items-md-center - - if meeting.organisers.any? - .mb-3.mb-md-0 - - meeting.organisers.each do |organiser| - = image_tag(organiser.avatar(26), class: 'rounded-circle', title: organiser.full_name, alt: organiser.full_name) - - if meeting.venue.present? - %div - = link_to meeting.venue.website, class: 'border-0 d-inline-block' do - = image_tag(meeting.venue.avatar.url, class: 'sponsor-sm', alt: meeting.venue.name) diff --git a/app/views/workshops/_workshop.html.haml b/app/views/workshops/_workshop.html.haml deleted file mode 100644 index b8cbe5e71..000000000 --- a/app/views/workshops/_workshop.html.haml +++ /dev/null @@ -1 +0,0 @@ -= render partial: 'events/event', locals: { event: workshop } diff --git a/config/cache.yml b/config/cache.yml new file mode 100644 index 000000000..da4a3a19a --- /dev/null +++ b/config/cache.yml @@ -0,0 +1,17 @@ +development: + store_options: + max_age: <%= 60.days.to_i %> + max_size: <%= 256.megabytes %> + namespace: <%= Rails.env %> + +test: + store_options: + max_age: <%= 60.days.to_i %> + max_size: <%= 256.megabytes %> + namespace: <%= Rails.env %> + +production: + store_options: + max_age: <%= 60.days.to_i %> + max_size: <%= 500.megabytes %> + namespace: <%= Rails.env %> diff --git a/config/environments/production.rb b/config/environments/production.rb index ba891f0c4..920dd1e47 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -52,7 +52,7 @@ config.active_support.report_deprecations = false # Replace the default in-process memory cache store with a durable alternative. - # config.cache_store = :mem_cache_store + config.cache_store = :solid_cache_store # Replace the default in-process and non-durable queuing backend for Active Job. # config.active_job.queue_adapter = :resque diff --git a/db/cache_schema.rb b/db/cache_schema.rb new file mode 100644 index 000000000..81a410d18 --- /dev/null +++ b/db/cache_schema.rb @@ -0,0 +1,12 @@ +ActiveRecord::Schema[7.2].define(version: 1) do + create_table "solid_cache_entries", force: :cascade do |t| + t.binary "key", limit: 1024, null: false + t.binary "value", limit: 536870912, null: false + t.datetime "created_at", null: false + t.integer "key_hash", limit: 8, null: false + t.integer "byte_size", limit: 4, null: false + t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size" + t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size" + t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true + end +end diff --git a/db/migrate/20260702102700_create_solid_cache_entries.rb b/db/migrate/20260702102700_create_solid_cache_entries.rb new file mode 100644 index 000000000..86b6409ad --- /dev/null +++ b/db/migrate/20260702102700_create_solid_cache_entries.rb @@ -0,0 +1,14 @@ +class CreateSolidCacheEntries < ActiveRecord::Migration[8.1] + def change + create_table "solid_cache_entries", force: :cascade do |t| + t.binary "key", limit: 1024, null: false + t.binary "value", limit: 536870912, null: false + t.datetime "created_at", null: false + t.integer "key_hash", limit: 8, null: false + t.integer "byte_size", limit: 4, null: false + t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size" + t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size" + t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 206576faf..b2be0a713 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_06_21_050948) do +ActiveRecord::Schema[8.1].define(version: 2026_07_02_102700) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -483,6 +483,17 @@ t.datetime "updated_at", precision: nil end + create_table "solid_cache_entries", force: :cascade do |t| + t.integer "byte_size", null: false + t.datetime "created_at", null: false + t.binary "key", null: false + t.bigint "key_hash", null: false + t.binary "value", null: false + t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size" + t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size" + t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true + end + create_table "sponsors", id: :serial, force: :cascade do |t| t.text "accessibility_info" t.string "avatar" diff --git a/script/benchmark_events.rb b/script/benchmark_events.rb new file mode 100755 index 000000000..91f721807 --- /dev/null +++ b/script/benchmark_events.rb @@ -0,0 +1,33 @@ +#!/usr/bin/env ruby +# Run: DB_NAME=codebar_dump bundle exec ruby script/benchmark_events.rb +ENV["RAILS_ENV"] ||= "development" +require_relative "../config/environment" +require "benchmark" + +session = ActionDispatch::Integration::Session.new(Rails.application) +session.host = "localhost" + +def measure(session, path) + qc = 0 + cb = ->(*, **) { qc += 1 } + time = nil + ActiveSupport::Notifications.subscribed(cb, "sql.active_record") do + time = Benchmark.measure { session.get(path) } + end + [time.real, qc] +end + +puts "=== Database: #{ActiveRecord::Base.connection_db_config.database} ===\n\n" + +%w[/events/upcoming /events/past /events/past?page=2].each do |path| + ActiveRecord::Base.connection.query_cache.clear + Rails.cache.clear if Rails.cache + + cold_t, cold_q = measure(session, path) + warm = 3.times.map { measure(session, path).first } + + puts "#{path}" + puts " cold: #{cold_t.round(3)}s | #{cold_q} queries" + puts " warm: #{warm.sort[1].round(3)}s (min #{warm.min.round(3)}, max #{warm.max.round(3)})" + puts +end diff --git a/spec/components/event_card_component_spec.rb b/spec/components/event_card_component_spec.rb new file mode 100644 index 000000000..9fd10b581 --- /dev/null +++ b/spec/components/event_card_component_spec.rb @@ -0,0 +1,90 @@ +require "rails_helper" +require "view_component/test_helpers" + +RSpec.describe EventCardComponent, type: :component do + include ViewComponent::TestHelpers + let(:chapter) { Fabricate(:chapter, active: true) } + + context "with a workshop" do + let(:workshop) { Fabricate(:workshop, chapter: chapter) } + let(:presenter) { WorkshopPresenter.new(workshop) } + + it "renders the workshop card" do + render_inline(described_class.new(event_card: presenter)) + expect(page).to have_css("[data-test='event']") + expect(page).to have_link(presenter.to_s) + expect(page).to have_content(presenter.date) + end + + it "does not render user-specific badges without a user" do + render_inline(described_class.new(event_card: presenter)) + expect(page).not_to have_text("Attending") + expect(page).not_to have_text("Manage") + end + + it "renders chapter badge" do + render_inline(described_class.new(event_card: presenter)) + expect(page).to have_link(chapter.name) + end + end + + context "with a meeting" do + let(:meeting) { Fabricate(:meeting) } + let(:presenter) { MeetingPresenter.new(meeting) } + + it "renders the meeting card" do + render_inline(described_class.new(event_card: presenter)) + expect(page).to have_css("[data-test='event']") + expect(page).to have_link(presenter.name) + end + + it "renders venue image for meetings" do + render_inline(described_class.new(event_card: presenter)) + expect(page).to have_css("img[alt='#{meeting.venue.name}']") + end + + it "does not render sponsor logos for meetings" do + render_inline(described_class.new(event_card: presenter)) + # Venue image has sponsor-sm class, so check for mx-1 spacing (used only by sponsors) + expect(page).not_to have_css(".mx-1") + end + end + + context "with an event" do + let(:event) { Fabricate(:event) } + let(:presenter) { EventPresenter.new(event) } + + it "renders the event card" do + render_inline(described_class.new(event_card: presenter)) + expect(page).to have_css("[data-test='event']") + expect(page).to have_link(event.name) + end + + it "renders sponsor logos for events" do + sponsor = Fabricate(:sponsor) + event.sponsors << sponsor + render_inline(described_class.new(event_card: presenter)) + expect(page).to have_css("img[alt='#{sponsor.name}']") + end + end + + context "with a user" do + let(:workshop) { Fabricate(:workshop, chapter: chapter) } + let(:presenter) { WorkshopPresenter.new(workshop) } + let(:member) { Fabricate(:member) } + + it "renders attending badge when user is attending (as presenter)" do + Fabricate(:workshop_invitation, workshop: workshop, member: member, attending: true) + user_presenter = MemberPresenter.new(member) + render_inline(described_class.new(event_card: presenter, user: user_presenter)) + expect(page).to have_text("Attending") + end + + it "renders attending badge when raw Member is passed" do + Fabricate(:workshop_invitation, workshop: workshop, member: member, attending: true) + render_inline(described_class.new(event_card: presenter, user: member)) + expect(page).to have_text("Attending") + end + end + +end