From 90ebe9e50edf1b15a061272bd7b9f3171d1e6d5f Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 7 Mar 2026 06:54:23 -0500 Subject: [PATCH 1/6] Fix analytics tracking: param name mismatch, windows type ID format, zero-result query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filter forms send params as `categories`, `sectors`, `windows_types` but the tracker looked for `category_ids`, `sector_ids`, `windows_type_ids` — so filter detail properties were never recorded. Also fix windows type chart reader to handle hash objects from enrich_filter_names, and add top-level `query` key to search_zero events so the "No Results" chart can read them. Co-Authored-By: Claude Opus 4.6 --- app/controllers/admin/ahoy_activities_controller.rb | 3 ++- app/services/analytics/ahoy_tracker.rb | 12 ++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/app/controllers/admin/ahoy_activities_controller.rb b/app/controllers/admin/ahoy_activities_controller.rb index 3c2b2a24b..badb6fc8f 100644 --- a/app/controllers/admin/ahoy_activities_controller.rb +++ b/app/controllers/admin/ahoy_activities_controller.rb @@ -174,10 +174,11 @@ def prepare_chart_data .sort_by { |_k, v| -v }.first(10).to_h # Windows types - batch lookup - wt_ids = events + wt_raw = events .where(name: [ "filter.workshops", "search.workshops" ]) .pluck(Arel.sql("JSON_EXTRACT(properties, '$.filters.windows_types')")) .flat_map { |arr| safe_json_parse(arr) } + wt_ids = wt_raw.map { |wt| wt.is_a?(Hash) ? wt["id"] : wt }.compact wt_names = WindowsType.where(id: wt_ids.uniq).pluck(:id, :short_name).to_h @ws_windows_types = wt_ids .map { |id| wt_names[id] }.compact.tally diff --git a/app/services/analytics/ahoy_tracker.rb b/app/services/analytics/ahoy_tracker.rb index 2cfe919d6..e7e509690 100644 --- a/app/services/analytics/ahoy_tracker.rb +++ b/app/services/analytics/ahoy_tracker.rb @@ -54,7 +54,10 @@ def track_index_intent(controller, resource_class, params:, result_count:) controller.ahoy.track("filter.#{resource_name}", properties) if cleaned[:filters].present? controller.ahoy.track("search.#{resource_name}", properties) if cleaned[:keywords].present? - controller.ahoy.track("search_zero.#{resource_name}", properties) if cleaned[:keywords].present? && result_count.zero? + if cleaned[:keywords].present? && result_count.zero? + query = cleaned[:keywords][:full_text] || cleaned[:keywords][:title] + controller.ahoy.track("search_zero.#{resource_name}", properties.merge(query: query).compact) + end end private @@ -171,9 +174,10 @@ def extract_search_params(params) Array(raw["resource_kind"]).presence # ---- OTHER FILTERS ---- - filters[:categories] = raw["category_ids"] if raw["category_ids"].present? - filters[:sectors] = raw["sector_ids"] if raw["sector_ids"].present? - filters[:windows_type] = raw["windows_type_ids"] if raw["windows_type_ids"].present? + # Forms may send as "category_ids" (edit form) or "categories" (filter form) + filters[:categories] = raw["category_ids"].presence || raw["categories"].presence + filters[:sectors] = raw["sector_ids"].presence || raw["sectors"].presence + filters[:windows_type] = raw["windows_type_ids"].presence || raw["windows_types"].presence filters.compact! From 369727b59a2dd4f493614f8223d8dc37be1fbc89 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 7 Mar 2026 06:54:29 -0500 Subject: [PATCH 2/6] Show chart titles even when data is empty via chart_card helper Chartkick hides the title when there's no data, replacing the entire chart with "No data" text. Add a chart_card helper that renders an HTML heading outside the canvas so admins always know which chart they're looking at. Co-Authored-By: Claude Opus 4.6 --- app/helpers/analytics_helper.rb | 7 + .../admin/ahoy_activities/charts.html.erb | 203 ++++++------------ 2 files changed, 75 insertions(+), 135 deletions(-) diff --git a/app/helpers/analytics_helper.rb b/app/helpers/analytics_helper.rb index 2a3435143..25e40c7c1 100644 --- a/app/helpers/analytics_helper.rb +++ b/app/helpers/analytics_helper.rb @@ -1,4 +1,11 @@ module AnalyticsHelper + def chart_card(title, &block) + content_tag(:div, class: "bg-white border border-gray-200 rounded-xl shadow-sm p-6") do + content_tag(:h3, title, class: "text-sm font-semibold text-gray-700 mb-2") + + capture(&block) + end + end + def print_button(record, printable_type: nil, path: admin_analytics_print_path, diff --git a/app/views/admin/ahoy_activities/charts.html.erb b/app/views/admin/ahoy_activities/charts.html.erb index 63a904abb..da0b98729 100644 --- a/app/views/admin/ahoy_activities/charts.html.erb +++ b/app/views/admin/ahoy_activities/charts.html.erb @@ -33,26 +33,23 @@

Visit patterns

-
- <%= column_chart scoped_visits.group_by_hour_of_day(:started_at, format: "%l %P").count, - title: "Average visits by Hour" %> -
+ <%= chart_card("Average visits by Hour") do %> + <%= column_chart scoped_visits.group_by_hour_of_day(:started_at, format: "%l %P").count %> + <% end %> -
- <%= column_chart scoped_visits.group_by_day_of_week(:started_at, format: "%A").count, - title: "Average visits by Weekday" %> -
+ <%= chart_card("Average visits by Weekday") do %> + <%= column_chart scoped_visits.group_by_day_of_week(:started_at, format: "%A").count %> + <% end %> -
+ <%= chart_card("Top Cities") do %> <%= bar_chart scoped_visits.where.not(city: nil) .group(:city) - .count.sort_by { |_c, v| -v }.first(10).to_h, - title: "Top Cities" %> -
+ .count.sort_by { |_c, v| -v }.first(10).to_h %> + <% end %> -
- <%= column_chart @session_duration_chart, title: "Session Duration" %> -
+ <%= chart_card("Session Duration") do %> + <%= column_chart @session_duration_chart %> + <% end %>
<%= @avg_events_per_visit %>
@@ -63,12 +60,11 @@
-
+ <%= chart_card("Visits by Date") do %> <%= line_chart scoped_visits.group_by_day(:started_at).count, xtitle: "Date", ytitle: "Visits", - height: "300px", width: "100%", - title: "Visits by Date" %> -
+ height: "300px", width: "100%" %> + <% end %>
@@ -114,41 +110,15 @@

Workshop analytics

-
- <%= bar_chart(@ws_category_types, title: "Workshop Search: Category Types") %> -
- -
- <%= bar_chart(@ws_category_names, title: "Workshop Search: Categories") %> -
- -
- <%= bar_chart(@ws_sectors, title: "Workshop Search: Sectors") %> -
- -
- <%= bar_chart(@ws_search_titles, title: "Workshop Search: Titles") %> -
- -
- <%= bar_chart(@ws_search_authors, title: "Workshop Search: Authors") %> -
- -
- <%= bar_chart(@ws_search_full_text, title: "Workshop Search: Full-Text") %> -
- -
- <%= bar_chart(@ws_windows_types, title: "Workshop search: Windows audiences") %> -
- -
- <%= bar_chart(@ws_zero_results, title: "Workshop Search: No Results") %> -
- -
- <%= column_chart(@ws_funnel, title: "Workshop Discovery Funnel") %> -
+ <%= chart_card("Workshop Search: Category Types") { bar_chart(@ws_category_types) } %> + <%= chart_card("Workshop Search: Categories") { bar_chart(@ws_category_names) } %> + <%= chart_card("Workshop Search: Sectors") { bar_chart(@ws_sectors) } %> + <%= chart_card("Workshop Search: Titles") { bar_chart(@ws_search_titles) } %> + <%= chart_card("Workshop Search: Authors") { bar_chart(@ws_search_authors) } %> + <%= chart_card("Workshop Search: Full-Text") { bar_chart(@ws_search_full_text) } %> + <%= chart_card("Workshop search: Windows audiences") { bar_chart(@ws_windows_types) } %> + <%= chart_card("Workshop Search: No Results") { bar_chart(@ws_zero_results) } %> + <%= chart_card("Workshop Discovery Funnel") { column_chart(@ws_funnel) } %>
@@ -157,17 +127,9 @@

Resource analytics

-
- <%= bar_chart(@rs_keywords, title: "Resource Search: Keywords") %> -
- -
- <%= bar_chart(@rs_kinds, title: "Resource Search: Kinds") %> -
- -
- <%= column_chart(@rs_funnel, title: "Resource Discovery Funnel") %> -
+ <%= chart_card("Resource Search: Keywords") { bar_chart(@rs_keywords) } %> + <%= chart_card("Resource Search: Kinds") { bar_chart(@rs_kinds) } %> + <%= chart_card("Resource Discovery Funnel") { column_chart(@rs_funnel) } %>
@@ -176,51 +138,32 @@

Content discovery

-
- <%= bar_chart(@tagging_sectors, title: "Tagging views: Sectors") %> -
- -
- <%= bar_chart(@tagging_categories, title: "Tagging views: Categories") %> -
- -
- <%= column_chart(@discovery_funnel, title: "User Discovery Funnel") %> -
+ <%= chart_card("Tagging views: Sectors") { bar_chart(@tagging_sectors) } %> + <%= chart_card("Tagging views: Categories") { bar_chart(@tagging_categories) } %> + <%= chart_card("User Discovery Funnel") { column_chart(@discovery_funnel) } %> -
+ <%= chart_card("Content Types People View Most") do %> <%= pie_chart( scoped_events .where("name LIKE 'view.%'") .group("SUBSTRING_INDEX(name, '.', -1)") - .count, - title: "Content Types People View Most" - ) %> -
+ .count) %> + <% end %> -
+ <%= chart_card("Content Types Printed Most") do %> <%= pie_chart( scoped_events .where("name LIKE 'print.%'") .group("SUBSTRING_INDEX(name, '.', -1)") - .count, - title: "Content Types Printed Most" - ) %> -
- -
- <%= pie_chart(@content_discovery, title: "How Users Discover Content") %> -
+ .count) %> + <% end %> -
- <%= pie_chart(@search_conversion, title: "Search-to-View Conversion") %> -
+ <%= chart_card("How Users Discover Content") { pie_chart(@content_discovery) } %> + <%= chart_card("Search-to-View Conversion") { pie_chart(@search_conversion) } %> -
- <%= line_chart @user_signup_trend, - title: "User Signup Trend", - points: true %> -
+ <%= chart_card("User Signup Trend") do %> + <%= line_chart @user_signup_trend, points: true %> + <% end %>
@@ -229,42 +172,33 @@

Referrals & engagement

-
+ <%= chart_card("Top Referrer → Landing Pages") do %> <%= bar_chart scoped_visits.group([:referring_domain, :landing_page]) - .count.sort_by { |_k, v| -v }.first(10).to_h, - title: "Top Referrer → Landing Pages" %> -
+ .count.sort_by { |_k, v| -v }.first(10).to_h %> + <% end %> -
+ <%= chart_card("Top Landing Pages") do %> <%= bar_chart scoped_visits.group(:landing_page) .order("count_id DESC") .limit(10) .count(:id), - title: "Top Landing Pages", height: "350px" %> -
+ height: "350px" %> + <% end %> -
- <%= bar_chart @top_engaged_users, - title: "Top Engaged Users", - library: { indexAxis: "y" } %> -
+ <%= chart_card("Top Engaged Users") do %> + <%= bar_chart @top_engaged_users, library: { indexAxis: "y" } %> + <% end %> -
- <%= pie_chart @bounce_rate, title: "Bounce Rate" %> -
+ <%= chart_card("Bounce Rate") { pie_chart @bounce_rate } %> -
- <%= pie_chart scoped_visits.group(:country).count, - title: "Visits by Country" %> -
+ <%= chart_card("Visits by Country") do %> + <%= pie_chart scoped_visits.group(:country).count %> + <% end %> -
- <%= bar_chart @top_exit_events, title: "Top Exit Events" %> -
+ <%= chart_card("Top Exit Events") { bar_chart @top_exit_events } %> -
+ <%= chart_card("Content Creation Velocity by Type") do %> <%= line_chart @creation_velocity_data, - title: "Content Creation Velocity by Type", height: "400px", points: true, curve: false, @@ -278,7 +212,7 @@ legend: { position: "top" }, yAxis: { title: { text: "Items Created" } } } %> -
+ <% end %>
@@ -287,21 +221,20 @@

Geographic & technical

-
- <%= pie_chart ({"Logged In" => scoped_visits.where.not(user_id: nil).count, - "Anonymous" => scoped_visits.where(user_id: nil).count }), - title: "Login status" %> -
-
<%= pie_chart scoped_visits.group(:device_type).count, title: "Device Type" %>
-
<%= pie_chart scoped_visits.group(:utm_source).count, title: "UTM Sources" %>
-
<%= pie_chart scoped_visits.group(:referring_domain).count, title: "Referring Domains" %>
-
<%= pie_chart scoped_visits.group(:browser).count, title: "Browsers" %>
-
+ <%= chart_card("Login status") do %> + <%= pie_chart({ "Logged In" => scoped_visits.where.not(user_id: nil).count, + "Anonymous" => scoped_visits.where(user_id: nil).count }) %> + <% end %> + <%= chart_card("Device Type") { pie_chart scoped_visits.group(:device_type).count } %> + <%= chart_card("UTM Sources") { pie_chart scoped_visits.group(:utm_source).count } %> + <%= chart_card("Referring Domains") { pie_chart scoped_visits.group(:referring_domain).count } %> + <%= chart_card("Browsers") { pie_chart scoped_visits.group(:browser).count } %> + <%= chart_card("New vs Returning Visitors") do %> <%= pie_chart({ "New" => scoped_visits.group(:visitor_token).having("count(*) = 1").count.size, "Returning" => scoped_visits.group(:visitor_token).having("count(*) > 1").count.size - }, title: "New vs Returning Visitors") %> -
+ }) %> + <% end %>
From c6b36e4aa1966716f4c35a1afdf14dd4c976bba6 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 7 Mar 2026 06:54:34 -0500 Subject: [PATCH 3/6] Add Ahoy visit/event seeds for analytics chart development Seed visits and events covering view, print, download, filter, search, search_zero, browse.taggings, and create actions so all admin activity charts have data in the dev environment. Co-Authored-By: Claude Opus 4.6 --- db/seeds/dummy_dev_seeds.rb | 250 ++++++++++++++++++++++++++++++++++++ 1 file changed, 250 insertions(+) diff --git a/db/seeds/dummy_dev_seeds.rb b/db/seeds/dummy_dev_seeds.rb index ab88126c7..b1c953ec6 100644 --- a/db/seeds/dummy_dev_seeds.rb +++ b/db/seeds/dummy_dev_seeds.rb @@ -1475,3 +1475,253 @@ priya.bookmarks.pluck(:bookmarkable_type, :bookmarkable_id) puts " #{shared.size} bookmarks shared between both users" end + +# ─── Ahoy visits & events (analytics charts) ─────────────────────────── +puts "Creating Ahoy visits and events for analytics charts…" + +ahoy_users = [ + User.find_by(email: "amy.user@example.com"), + User.find_by(email: "priya.user@example.com"), + nil # anonymous visitor +].compact + +cities = ["Los Angeles", "San Diego", "Portland", "Seattle", "Denver"] +browsers = %w[Chrome Safari Firefox Edge] +devices = %w[Desktop Mobile Tablet] + +# Create visits spread over the past month +ahoy_visits = [] +30.times do |day_offset| + rand(2..4).times do + user = ahoy_users.sample + visit = Ahoy::Visit.create!( + visit_token: SecureRandom.uuid, + visitor_token: SecureRandom.uuid, + user: user, + started_at: (30 - day_offset).days.ago + rand(0..23).hours, + browser: browsers.sample, + device_type: devices.sample, + city: cities.sample, + country: "US", + landing_page: %w[/workshops /resources /stories /].sample + ) + ahoy_visits << visit + end +end + +# Gather real records for view/print events +view_targets = { + "workshop" => Workshop.published.limit(6).to_a, + "resource" => Resource.published.limit(6).to_a, + "person" => Person.limit(4).to_a, + "story" => Story.published.limit(4).to_a, + "event" => Event.limit(3).to_a, + "community_news" => CommunityNews.published.limit(3).to_a, + "workshop_variation" => WorkshopVariation.published.limit(3).to_a, + "tutorial" => Tutorial.limit(3).to_a +}.reject { |_, v| v.empty? } + +# ── view.* events (populates "Content Types People View Most" pie chart) ── +view_targets.each do |resource_name, records| + weight = resource_name == "workshop" ? 8 : (resource_name == "resource" ? 5 : 3) + weight.times do + record = records.sample + visit = ahoy_visits.sample + Ahoy::Event.create!( + visit: visit, + user: visit.user, + name: "view.#{resource_name}", + resource_type: record.class.name, + resource_id: record.id, + properties: { resource_type: record.class.name, resource_id: record.id, resource_title: record.try(:title) || record.try(:name) }, + time: visit.started_at + rand(1..300).seconds + ) + end +end + +# ── print.* events ── +%w[workshop resource story community_news].each do |resource_name| + next unless view_targets[resource_name]&.any? + + rand(2..4).times do + record = view_targets[resource_name].sample + visit = ahoy_visits.sample + Ahoy::Event.create!( + visit: visit, + user: visit.user, + name: "print.#{resource_name}", + resource_type: record.class.name, + resource_id: record.id, + properties: { resource_type: record.class.name, resource_id: record.id, resource_title: record.try(:title) || record.try(:name) }, + time: visit.started_at + rand(60..600).seconds + ) + end +end + +# ── download.resource events ── +if view_targets["resource"]&.any? + 3.times do + record = view_targets["resource"].sample + visit = ahoy_visits.sample + Ahoy::Event.create!( + visit: visit, + user: visit.user, + name: "download.resource", + resource_type: "Resource", + resource_id: record.id, + properties: { resource_type: "Resource", resource_id: record.id, resource_title: record.try(:title) }, + time: visit.started_at + rand(60..600).seconds + ) + end +end + +# ── filter.workshops / search.workshops events (populates category/sector/windows type charts) ── +seed_categories = Category.joins(:category_type).where(published: true).limit(15).to_a +seed_sectors = Sector.where(published: true).limit(10).to_a +seed_windows_types = WindowsType.all.to_a + +search_titles = ["self care", "container of feelings", "self-care", "resilience", "touchstones", + "domestic violence", "grief", "personal needs flower", "butterfly", "may you be"] +search_authors = ["fabian", "aaron", "power and control wheel", "aaron mason", "Janet Hughes"] +search_full_texts = ["anxiety", "we rise", "luck", "collage", "mental wellness", + "mental well-being", "friendship", "transforming", "north star"] + +# Filter events with categories, sectors, and windows types +12.times do + visit = ahoy_visits.sample + cats = seed_categories.sample(rand(1..3)).map { |c| { id: c.id, name: c.name, type: c.category_type&.name } } + secs = seed_sectors.sample(rand(1..2)).map { |s| { id: s.id, name: s.name } } + wts = seed_windows_types.sample(rand(1..2)).map(&:id) + + props = { + resource_type: "Workshop", + result_count: rand(3..40), + filters: { categories: cats, sectors: secs, windows_types: wts } + } + + Ahoy::Event.create!( + visit: visit, + user: visit.user, + name: "filter.workshops", + properties: props, + time: visit.started_at + rand(10..120).seconds + ) +end + +# Search events with keywords AND filters +10.times do + visit = ahoy_visits.sample + cats = seed_categories.sample(rand(1..2)).map { |c| { id: c.id, name: c.name, type: c.category_type&.name } } + secs = seed_sectors.sample(rand(1..2)).map { |s| { id: s.id, name: s.name } } + wts = seed_windows_types.sample(rand(1..2)).map(&:id) + + props = { + resource_type: "Workshop", + result_count: rand(1..20), + keywords: { + title: search_titles.sample, + author: search_authors.sample, + full_text: search_full_texts.sample + }, + filters: { categories: cats, sectors: secs, windows_types: wts } + } + + Ahoy::Event.create!( + visit: visit, + user: visit.user, + name: "search.workshops", + properties: props, + time: visit.started_at + rand(10..120).seconds + ) +end + +# ── search_zero.workshops events (populates "No Results" chart) ── +zero_queries = ["watercolor techniques for teens", "music therapy", "yoga breathing", + "sand tray", "outdoor art", "digital collage", "puppet making 101", + "grief journaling advanced"] +zero_queries.each do |query| + visit = ahoy_visits.sample + rand(1..3).times do + Ahoy::Event.create!( + visit: visit, + user: visit.user, + name: "search_zero.workshops", + properties: { + resource_type: "Workshop", + result_count: 0, + query: query, + keywords: { full_text: query } + }, + time: visit.started_at + rand(10..300).seconds + ) + end +end + +# ── browse.taggings events (supplements tagging charts) ── +5.times do + visit = ahoy_visits.sample + Ahoy::Event.create!( + visit: visit, + user: visit.user, + name: "browse.taggings", + properties: { + sectors: seed_sectors.sample(rand(1..3)).map(&:name), + categories: seed_categories.sample(rand(1..3)).map(&:name), + page_result_count: rand(5..30) + }, + time: visit.started_at + rand(10..300).seconds + ) +end + +# ── filter.resources / search.resources events ── +resource_keywords = ["art supplies guide", "facilitator handbook", "trauma informed", "group activity", + "healing through art", "coloring pages", "workshop template"] +resource_kinds = %w[pdf video link document] + +5.times do + visit = ahoy_visits.sample + Ahoy::Event.create!( + visit: visit, + user: visit.user, + name: "filter.resources", + properties: { resource_type: "Resource", result_count: rand(2..15), filters: { kind: resource_kinds.sample } }, + time: visit.started_at + rand(10..120).seconds + ) +end + +5.times do + visit = ahoy_visits.sample + Ahoy::Event.create!( + visit: visit, + user: visit.user, + name: "search.resources", + properties: { resource_type: "Resource", result_count: rand(1..10), keywords: { full_text: resource_keywords.sample } }, + time: visit.started_at + rand(10..120).seconds + ) +end + +# ── create.* events (populates "Content Creation Velocity" chart) ── +if view_targets["workshop"]&.any? + %w[workshop_idea story_idea workshop_log quote bookmark].each do |model_name| + klass = model_name.classify.safe_constantize + next unless klass + + records = klass.limit(3).to_a + next if records.empty? + + records.each do |record| + visit = ahoy_visits.sample + Ahoy::Event.create!( + visit: visit, + user: visit.user, + name: "create.#{model_name}", + resource_type: record.class.name, + resource_id: record.id, + properties: { resource_type: record.class.name, resource_id: record.id, resource_title: record.try(:title) || record.try(:name) }, + time: visit.started_at + rand(60..600).seconds + ) + end + end +end + +puts " Created #{Ahoy::Visit.count} visits, #{Ahoy::Event.count} events" From d8cfe1ce6d36829e8357545885453ed1340a71d5 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 7 Mar 2026 06:54:41 -0500 Subject: [PATCH 4/6] Add tests for analytics chart data and tracker param fix Integration tests verify chart titles render with no data and that seeded event data populates all previously-empty charts. Unit tests validate the extract_search_params fix handles both filter form and edit form param names, and that search_zero events include the top-level query key. Co-Authored-By: Claude Opus 4.6 --- spec/requests/admin/ahoy_activities_spec.rb | 191 +++++++++++++++++++ spec/services/analytics/ahoy_tracker_spec.rb | 110 +++++++++++ 2 files changed, 301 insertions(+) create mode 100644 spec/services/analytics/ahoy_tracker_spec.rb diff --git a/spec/requests/admin/ahoy_activities_spec.rb b/spec/requests/admin/ahoy_activities_spec.rb index 67db19eee..c4b9d0b4c 100644 --- a/spec/requests/admin/ahoy_activities_spec.rb +++ b/spec/requests/admin/ahoy_activities_spec.rb @@ -213,6 +213,197 @@ get charts_path expect(response).to have_http_status(:ok) end + + it "always renders chart titles even with no data" do + get charts_path, params: { time_period: "all_time" } + body = response.body + + [ + "Workshop Search: Category Types", + "Workshop Search: Categories", + "Workshop Search: Sectors", + "Workshop Search: Titles", + "Workshop Search: Authors", + "Workshop Search: Full-Text", + "Workshop Search: Windows Types", + "Workshop Search: No Results", + "Workshop Discovery Funnel", + "Content Types People View Most", + "Content Types Printed Most", + "How Users Discover Content", + "Search-to-View Conversion", + "User Signup Trend" + ].each do |title| + expect(body).to include(title), "Expected chart title '#{title}' to be present" + end + end + end + + # ============================================================== + # Charts with populated data + # ============================================================== + + describe "GET /admin/activities/charts with event data" do + let(:visit) { create(:ahoy_visit, user: user, started_at: 2.days.ago) } + + let(:category_type) { create(:category_type, :published, name: "ArtType") } + let(:category) { create(:category, :published, name: "Drawing", category_type: category_type) } + let(:sector) { create(:sector, :published, name: "Domestic Violence") } + let(:windows_type) { create(:windows_type, :adult) } + + let!(:filter_event) do + create( + :ahoy_event, + name: "filter.workshops", + user: user, + visit: visit, + time: 2.days.ago, + properties: { + "resource_type" => "Workshop", + "result_count" => 5, + "filters" => { + "categories" => [ { "id" => category.id, "name" => category.name, "type" => category_type.name } ], + "sectors" => [ { "id" => sector.id, "name" => sector.name } ], + "windows_types" => [ { "id" => windows_type.id, "name" => windows_type.name } ] + } + } + ) + end + + let!(:search_event) do + create( + :ahoy_event, + name: "search.workshops", + user: user, + visit: visit, + time: 2.days.ago, + properties: { + "resource_type" => "Workshop", + "result_count" => 3, + "keywords" => { "title" => "self care", "author" => "fabian", "full_text" => "anxiety" }, + "filters" => { + "categories" => [ { "id" => category.id, "name" => category.name, "type" => category_type.name } ], + "sectors" => [ { "id" => sector.id, "name" => sector.name } ], + "windows_types" => [ { "id" => windows_type.id, "name" => windows_type.name } ] + } + } + ) + end + + let!(:zero_result_event) do + create( + :ahoy_event, + name: "search_zero.workshops", + user: user, + visit: visit, + time: 2.days.ago, + properties: { + "resource_type" => "Workshop", + "result_count" => 0, + "query" => "music therapy", + "keywords" => { "full_text" => "music therapy" } + } + ) + end + + let!(:view_workshop_event) do + create( + :ahoy_event, + name: "view.workshop", + user: user, + visit: visit, + resource_type: "Workshop", + resource_id: 1, + time: 2.days.ago, + properties: { "resource_type" => "Workshop", "resource_id" => 1 } + ) + end + + let!(:view_resource_event) do + create( + :ahoy_event, + name: "view.resource", + user: user, + visit: visit, + resource_type: "Resource", + resource_id: 1, + time: 2.days.ago, + properties: { "resource_type" => "Resource", "resource_id" => 1 } + ) + end + + let!(:print_event) do + create( + :ahoy_event, + name: "print.workshop", + user: user, + visit: visit, + resource_type: "Workshop", + resource_id: 1, + time: 2.days.ago, + properties: { "resource_type" => "Workshop", "resource_id" => 1 } + ) + end + + let!(:tagging_event) do + create( + :ahoy_event, + name: "browse.taggings", + user: user, + visit: visit, + time: 2.days.ago, + properties: { + "sectors" => [ sector.name ], + "categories" => [ category.name ], + "page_result_count" => 10 + } + ) + end + + it "renders charts page successfully" do + get charts_path, params: { time_period: "all_time" } + expect(response).to have_http_status(:ok) + end + + it "populates workshop filter charts with category, sector, and windows type data" do + get charts_path, params: { time_period: "all_time" } + body = response.body + + expect(body).to include(category_type.name) + expect(body).to include(category.name) + expect(body).to include(sector.name) + expect(body).to include(windows_type.short_name) + end + + it "populates workshop keyword search charts" do + get charts_path, params: { time_period: "all_time" } + body = response.body + + expect(body).to include("self care") + expect(body).to include("fabian") + expect(body).to include("anxiety") + end + + it "populates zero-result search chart" do + get charts_path, params: { time_period: "all_time" } + expect(response.body).to include("music therapy") + end + + it "populates content type view pie chart with multiple types" do + get charts_path, params: { time_period: "all_time" } + body = response.body + + expect(body).to include("workshop") + expect(body).to include("resource") + end + + it "populates tagging charts with sector and category names" do + get charts_path, params: { time_period: "all_time" } + body = response.body + + expect(body).to include(sector.name) + expect(body).to include(category.name) + end end end end diff --git a/spec/services/analytics/ahoy_tracker_spec.rb b/spec/services/analytics/ahoy_tracker_spec.rb new file mode 100644 index 000000000..00387eb46 --- /dev/null +++ b/spec/services/analytics/ahoy_tracker_spec.rb @@ -0,0 +1,110 @@ +require "rails_helper" + +RSpec.describe Analytics::AhoyTracker do + describe ".extract_search_params (via track_index_intent)" do + let(:user) { create(:user) } + let(:category_type) { create(:category_type, :published, name: "ArtType") } + let(:category) { create(:category, :published, name: "Drawing", category_type: category_type) } + let(:sector) { create(:sector, :published, name: "Domestic Violence") } + let(:windows_type) { create(:windows_type, :adult) } + + let(:controller) do + instance_double(WorkshopsController).tap do |ctrl| + ahoy = instance_double(Ahoy::Tracker, visit_token: "abc-123") + allow(ctrl).to receive(:ahoy).and_return(ahoy) + allow(ctrl).to receive(:current_user).and_return(user) + allow(ahoy).to receive(:track) + allow(ahoy).to receive(:visit).and_return(nil) + end + end + + it "captures categories from filter form param name" do + params = ActionController::Parameters.new( + "categories" => { category.id.to_s => category.id.to_s } + ) + + described_class.track_index_intent( + controller, Workshop, params: params, result_count: 5 + ) + + expect(controller.ahoy).to have_received(:track).with( + "filter.workshops", + hash_including(filters: hash_including(:categories)) + ) + end + + it "captures sectors from filter form param name" do + params = ActionController::Parameters.new( + "sectors" => { sector.id.to_s => sector.id.to_s } + ) + + described_class.track_index_intent( + controller, Workshop, params: params, result_count: 5 + ) + + expect(controller.ahoy).to have_received(:track).with( + "filter.workshops", + hash_including(filters: hash_including(:sectors)) + ) + end + + it "captures windows_types from filter form param name" do + params = ActionController::Parameters.new( + "windows_types" => { windows_type.id.to_s => windows_type.id.to_s } + ) + + described_class.track_index_intent( + controller, Workshop, params: params, result_count: 5 + ) + + expect(controller.ahoy).to have_received(:track).with( + "filter.workshops", + hash_including(filters: hash_including(:windows_types)) + ) + end + + it "still captures category_ids from edit form param name" do + params = ActionController::Parameters.new( + "category_ids" => [ category.id.to_s ] + ) + + described_class.track_index_intent( + controller, Workshop, params: params, result_count: 5 + ) + + expect(controller.ahoy).to have_received(:track).with( + "filter.workshops", + hash_including(filters: hash_including(:categories)) + ) + end + + it "includes query in search_zero events" do + params = ActionController::Parameters.new( + "query" => "music therapy" + ) + + described_class.track_index_intent( + controller, Workshop, params: params, result_count: 0 + ) + + expect(controller.ahoy).to have_received(:track).with( + "search_zero.workshops", + hash_including(query: "music therapy") + ) + end + + it "does not fire search_zero when results exist" do + params = ActionController::Parameters.new( + "query" => "self care" + ) + + described_class.track_index_intent( + controller, Workshop, params: params, result_count: 5 + ) + + expect(controller.ahoy).not_to have_received(:track).with( + "search_zero.workshops", anything + ) + end + end +end From 2cc316832cc74eb8c7f54b40ce3ecea31b9f0053 Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 9 Mar 2026 12:35:16 -0400 Subject: [PATCH 5/6] Update chart title test to match renamed Windows audiences heading Co-Authored-By: Claude Opus 4.6 --- spec/requests/admin/ahoy_activities_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/requests/admin/ahoy_activities_spec.rb b/spec/requests/admin/ahoy_activities_spec.rb index c4b9d0b4c..5b1252b2f 100644 --- a/spec/requests/admin/ahoy_activities_spec.rb +++ b/spec/requests/admin/ahoy_activities_spec.rb @@ -225,7 +225,7 @@ "Workshop Search: Titles", "Workshop Search: Authors", "Workshop Search: Full-Text", - "Workshop Search: Windows Types", + "Workshop search: Windows audiences", "Workshop Search: No Results", "Workshop Discovery Funnel", "Content Types People View Most", From 8c4f3bb745830b3c5ff6274fd450d8297f0c91bb Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 9 Mar 2026 12:36:51 -0400 Subject: [PATCH 6/6] Fix RuboCop SpaceInsideArrayLiteralBrackets in seed arrays Co-Authored-By: Claude Opus 4.6 --- db/seeds/dummy_dev_seeds.rb | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/db/seeds/dummy_dev_seeds.rb b/db/seeds/dummy_dev_seeds.rb index b1c953ec6..e9c9c3445 100644 --- a/db/seeds/dummy_dev_seeds.rb +++ b/db/seeds/dummy_dev_seeds.rb @@ -1485,7 +1485,7 @@ nil # anonymous visitor ].compact -cities = ["Los Angeles", "San Diego", "Portland", "Seattle", "Denver"] +cities = [ "Los Angeles", "San Diego", "Portland", "Seattle", "Denver" ] browsers = %w[Chrome Safari Firefox Edge] devices = %w[Desktop Mobile Tablet] @@ -1580,11 +1580,11 @@ seed_sectors = Sector.where(published: true).limit(10).to_a seed_windows_types = WindowsType.all.to_a -search_titles = ["self care", "container of feelings", "self-care", "resilience", "touchstones", - "domestic violence", "grief", "personal needs flower", "butterfly", "may you be"] -search_authors = ["fabian", "aaron", "power and control wheel", "aaron mason", "Janet Hughes"] -search_full_texts = ["anxiety", "we rise", "luck", "collage", "mental wellness", - "mental well-being", "friendship", "transforming", "north star"] +search_titles = [ "self care", "container of feelings", "self-care", "resilience", "touchstones", + "domestic violence", "grief", "personal needs flower", "butterfly", "may you be" ] +search_authors = [ "fabian", "aaron", "power and control wheel", "aaron mason", "Janet Hughes" ] +search_full_texts = [ "anxiety", "we rise", "luck", "collage", "mental wellness", + "mental well-being", "friendship", "transforming", "north star" ] # Filter events with categories, sectors, and windows types 12.times do @@ -1636,9 +1636,9 @@ end # ── search_zero.workshops events (populates "No Results" chart) ── -zero_queries = ["watercolor techniques for teens", "music therapy", "yoga breathing", - "sand tray", "outdoor art", "digital collage", "puppet making 101", - "grief journaling advanced"] +zero_queries = [ "watercolor techniques for teens", "music therapy", "yoga breathing", + "sand tray", "outdoor art", "digital collage", "puppet making 101", + "grief journaling advanced" ] zero_queries.each do |query| visit = ahoy_visits.sample rand(1..3).times do @@ -1674,8 +1674,8 @@ end # ── filter.resources / search.resources events ── -resource_keywords = ["art supplies guide", "facilitator handbook", "trauma informed", "group activity", - "healing through art", "coloring pages", "workshop template"] +resource_keywords = [ "art supplies guide", "facilitator handbook", "trauma informed", "group activity", + "healing through art", "coloring pages", "workshop template" ] resource_kinds = %w[pdf video link document] 5.times do