diff --git a/app/controllers/admin/ahoy_activities_controller.rb b/app/controllers/admin/ahoy_activities_controller.rb
index bbde0a21f..23c9a5a59 100644
--- a/app/controllers/admin/ahoy_activities_controller.rb
+++ b/app/controllers/admin/ahoy_activities_controller.rb
@@ -5,117 +5,160 @@ class AhoyActivitiesController < ApplicationController
def index
authorize! :ahoy_activity, to: :index?
- @users = params[:user_id].present? ? User.where(id: params[:user_id].to_s.split("--")) : nil
+ if turbo_frame_request?
+ per_page = params[:per_page].presence&.to_i || 20
+ base_scope = Ahoy::Event.includes(:user, :visit)
+ filtered = apply_event_filters(base_scope)
+
+ sortable = %w[time name user]
+ @sort = sortable.include?(params[:sort]) ? params[:sort] : "time"
+ @sort_direction = params[:direction] == "asc" ? "asc" : "desc"
+ filtered = apply_event_sort(filtered, @sort, @sort_direction)
+
+ @events = filtered.paginate(page: params[:page], per_page: per_page)
+ base_count = base_scope.count
+ filtered_count = filtered.count
+ @count_display = filtered_count == base_count ? base_count : "#{filtered_count}/#{base_count}"
+
+ render :index_lazy
+ else
+ render :index
+ end
+ end
- page = params[:page].presence&.to_i || 1
- per_page = params[:per_page].presence&.to_i || 20
+ def show
+ authorize! :ahoy_activity, to: :show?
+ @event = Ahoy::Event.includes(:user, :visit).find(params[:id])
+ @resource_path = safe_resource_path(@event.resource_type, @event.resource_id)
+ end
- scope = Ahoy::Event.includes(:user, :visit).order(time: :desc)
+ def visits
+ authorize! :ahoy_activity, to: :visits?
- # Only real content interactions (not search/filter noise)
- if params[:prefixes].present?
- prefixes = params[:prefixes].split("--").map(&:strip)
+ if turbo_frame_request?
+ per_page = params[:per_page].presence&.to_i || 20
+ base_scope = Ahoy::Visit
+ .includes(:user)
+ .left_joins(:events)
+ .select("ahoy_visits.*, COUNT(ahoy_events.id) AS events_count, TIMESTAMPDIFF(MINUTE, ahoy_visits.started_at, MAX(ahoy_events.time)) AS duration_minutes")
+ .group("ahoy_visits.id")
+ filtered = apply_visit_filters(base_scope)
+
+ sortable = %w[started_at user events_count duration]
+ @sort = sortable.include?(params[:sort]) ? params[:sort] : "started_at"
+ @sort_direction = params[:direction] == "asc" ? "asc" : "desc"
+ filtered = apply_visit_sort(filtered, @sort, @sort_direction)
+
+ @visits = filtered.paginate(page: params[:page], per_page: per_page)
+ base_count = base_scope.reselect("ahoy_visits.id").count.size
+ filtered_count = filtered.reselect("ahoy_visits.id").count.size
+ @count_display = filtered_count == base_count ? base_count : "#{filtered_count}/#{base_count}"
+
+ render :visits_lazy
else
- prefixes = nil # %w[ create update destroy auth ] # view browse print download
- end
- if prefixes.present?
- scope = scope.where(prefixes.map { |p| "ahoy_events.name LIKE ?" }.join(" OR "),
- *prefixes.map { |p| "#{p}.%" })
+ render :visits
end
+ end
- # Filter by user (if viewing specific user activity)
- scope = scope.where(user: @users) if @users.present?
+ def charts
+ authorize! :ahoy_activity, to: :charts?
+ @creation_velocity_data = creation_velocity_data
+ prepare_chart_data
+ end
- # Time filter
+ private
+
+ def apply_event_filters(scope)
+ scope = scope.where(user_id: params[:user_id]) if params[:user_id].present?
scope = scope.where(time: time_range) if time_range.present?
if params[:from].present?
- from_time = Time.zone.parse(params[:from]).beginning_of_day
- scope = scope.where("ahoy_events.time >= ?", from_time)
+ scope = scope.where("ahoy_events.time >= ?", Time.zone.parse(params[:from]).beginning_of_day)
end
-
if params[:to].present?
- to_time = Time.zone.parse(params[:to]).end_of_day
- scope = scope.where("ahoy_events.time <= ?", to_time)
+ scope = scope.where("ahoy_events.time <= ?", Time.zone.parse(params[:to]).end_of_day)
end
- # Filter by visit
- if params[:visit_id].present?
- scope = scope.where(visit_id: params[:visit_id])
+ if params[:prefixes].present?
+ prefixes = params[:prefixes].split("--").map(&:strip)
+ scope = scope.where(prefixes.map { "ahoy_events.name LIKE ?" }.join(" OR "),
+ *prefixes.map { |p| "#{p}.%" })
end
- # Audience filter
+ scope = scope.where(visit_id: params[:visit_id]) if params[:visit_id].present?
scope = apply_audience_filter(scope)
+ scope = scope.where(resource_type: params[:resource_type]) if params[:resource_type].present?
+ scope = scope.where(resource_id: params[:resource_id]) if params[:resource_id].present?
- # Filter by resource type and ID
- if params[:resource_type].present?
- scope = scope.where(resource_type: params[:resource_type])
+ if params[:event_name].present?
+ term = Ahoy::Event.sanitize_sql_like(params[:event_name])
+ scope = scope.where("ahoy_events.name LIKE ?", "%#{term}%")
end
- if params[:resource_id].present?
- scope = scope.where(resource_id: params[:resource_id])
+ if params[:resource_name].present?
+ term = Ahoy::Event.sanitize_sql_like(params[:resource_name])
+ scope = scope.where(
+ "LOWER(ahoy_events.properties->>'$.resource_title') LIKE LOWER(?)",
+ "%#{term}%"
+ )
end
- @events = scope.paginate(page: page, per_page: per_page)
- end
+ if params[:props].present?
+ term = Ahoy::Event.sanitize_sql_like(params[:props])
+ scope = scope.where("CAST(ahoy_events.properties AS CHAR) LIKE ?", "%#{term}%")
+ end
- def show
- authorize! :ahoy_activity, to: :show?
- @event = Ahoy::Event.includes(:user, :visit).find(params[:id])
- @resource_path = safe_resource_path(@event.resource_type, @event.resource_id)
+ scope
end
- def visits
- authorize! :ahoy_activity, to: :visits?
-
- page = params[:page].presence&.to_i || 1
- per_page = params[:per_page].presence&.to_i || 20
-
- scope = Ahoy::Visit
- .includes(:user)
- .left_joins(:events)
- .select("ahoy_visits.*, COUNT(ahoy_events.id) AS events_count")
- .group("ahoy_visits.id")
- .order(started_at: :desc)
-
- # Filter by user
- if params[:user_id].present?
- scope = scope.where(user_id: params[:user_id])
- end
-
- # Filter by visit
- if params[:visit_id].present?
- scope = scope.where(id: params[:visit_id])
+ def apply_event_sort(scope, column, direction)
+ dir = direction.to_sym
+ case column
+ when "time"
+ scope.reorder(time: dir)
+ when "name"
+ scope.reorder(name: dir)
+ when "user"
+ scope.left_joins(:user)
+ .reorder(Arel.sql("users.first_name #{direction}, users.last_name #{direction}"))
+ else
+ scope.reorder(time: :desc)
end
+ end
- # Time period filter
+ def apply_visit_filters(scope)
+ scope = scope.where(user_id: params[:user_id]) if params[:user_id].present?
+ scope = scope.where(id: params[:visit_id]) if params[:visit_id].present?
scope = scope.where(started_at: time_range) if time_range
-
- # Audience filter
scope = apply_audience_filter(scope)
- # Date filtering
if params[:from].present?
- from_time = Time.zone.parse(params[:from]).beginning_of_day
- scope = scope.where("ahoy_visits.started_at >= ?", from_time)
+ scope = scope.where("ahoy_visits.started_at >= ?", Time.zone.parse(params[:from]).beginning_of_day)
end
-
if params[:to].present?
- to_time = Time.zone.parse(params[:to]).end_of_day
- scope = scope.where("ahoy_visits.started_at <= ?", to_time)
+ scope = scope.where("ahoy_visits.started_at <= ?", Time.zone.parse(params[:to]).end_of_day)
end
- @visits = scope.paginate(page: page, per_page: per_page)
+ scope
end
- def charts
- authorize! :ahoy_activity, to: :charts?
- @creation_velocity_data = creation_velocity_data
- prepare_chart_data
+ def apply_visit_sort(scope, column, direction)
+ dir = direction.to_sym
+ case column
+ when "started_at"
+ scope.reorder(started_at: dir)
+ when "user"
+ scope.left_joins(:user)
+ .reorder(Arel.sql("users.first_name #{direction}, users.last_name #{direction}"))
+ when "events_count"
+ scope.reorder(Arel.sql("events_count #{direction}"))
+ when "duration"
+ scope.reorder(Arel.sql("duration_minutes #{direction}"))
+ else
+ scope.reorder(started_at: :desc)
+ end
end
- private
-
def prepare_chart_data
events = scoped_events
diff --git a/app/frontend/javascript/controllers/checkbox_select_controller.js b/app/frontend/javascript/controllers/checkbox_select_controller.js
new file mode 100644
index 000000000..f130a858a
--- /dev/null
+++ b/app/frontend/javascript/controllers/checkbox_select_controller.js
@@ -0,0 +1,32 @@
+import { Controller } from "@hotwired/stimulus";
+
+// Connects to data-controller="checkbox-select"
+// Updates a label from checked checkboxes and optionally submits the parent form.
+export default class extends Controller {
+ static targets = ["label"];
+ static values = {
+ labels: Object,
+ fieldName: String,
+ allLabel: { type: String, default: "All" },
+ autoSubmit: { type: Boolean, default: true }
+ };
+
+ update() {
+ const name = this.fieldNameValue || "audience[]";
+ const checkboxes = this.element.querySelectorAll(
+ `input[name="${name}"]`,
+ );
+ const checked = [...checkboxes]
+ .filter((c) => c.checked)
+ .map((c) => this.labelsValue[c.value]);
+ const total = Object.keys(this.labelsValue).length;
+
+ this.labelTarget.textContent =
+ checked.length === total ? this.allLabelValue : checked.join(", ");
+
+ if (this.autoSubmitValue) {
+ const form = this.element.closest("form");
+ if (form) form.requestSubmit();
+ }
+ }
+}
diff --git a/app/frontend/javascript/controllers/collection_controller.js b/app/frontend/javascript/controllers/collection_controller.js
index b9f56ce46..ceea92bae 100644
--- a/app/frontend/javascript/controllers/collection_controller.js
+++ b/app/frontend/javascript/controllers/collection_controller.js
@@ -11,17 +11,22 @@ export default class extends Controller {
this.toggleClass(event.target);
}
+ if (type === "select-one" || type === "select-multiple" || type === "date") {
+ this.stylePlaceholder(event.target);
+ }
+
if (
type === "checkbox" ||
type === "radio" ||
type === "select-one" ||
- type === "select-multiple"
+ type === "select-multiple" ||
+ type === "date"
) {
this.submitForm();
}
});
this.element.addEventListener("input", (event) => {
- if (event.target.type === "text") {
+ if (event.target.type === "text" || event.target.type === "number") {
this.debouncedSubmit();
}
});
@@ -57,11 +62,13 @@ export default class extends Controller {
clearAndSubmit(event) {
event.preventDefault();
- this.element.querySelectorAll('input[type="text"], input[type="search"]').forEach(input => {
+ this.element.querySelectorAll('input[type="text"], input[type="search"], input[type="number"], input[type="date"]').forEach(input => {
input.value = '';
+ if (input.type === "date") this.stylePlaceholder(input);
});
this.element.querySelectorAll('select').forEach(select => {
select.selectedIndex = 0;
+ this.stylePlaceholder(select);
});
this.element.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach(input => {
if (input.checked) {
@@ -73,6 +80,12 @@ export default class extends Controller {
this.submitForm();
}
+ stylePlaceholder(el) {
+ const isBlank = !el.value;
+ el.classList.toggle("text-gray-500", isBlank);
+ el.classList.toggle("text-gray-900", !isBlank);
+ }
+
blurOldResults() {
const frame = this.element.closest("turbo-frame");
const scope = frame || document;
diff --git a/app/frontend/javascript/controllers/filter_chip_controller.js b/app/frontend/javascript/controllers/filter_chip_controller.js
new file mode 100644
index 000000000..ffdfbd0b5
--- /dev/null
+++ b/app/frontend/javascript/controllers/filter_chip_controller.js
@@ -0,0 +1,63 @@
+import { Controller } from "@hotwired/stimulus";
+
+// Connects to data-controller="filter-chip"
+// Dismisses filter chips by clearing the matching input in the collection
+// form (for search params) or navigating to a modified URL (for header params).
+export default class extends Controller {
+ remove(event) {
+ event.preventDefault();
+ const button = event.currentTarget;
+ const param = button.dataset.param;
+ const value = button.dataset.value;
+
+ const collectionForm = document.querySelector(
+ '[data-controller="collection"]',
+ );
+
+ // Search params: clear input in collection form and re-submit
+ if (collectionForm && param !== "audience" && param !== "time_period") {
+ const input = collectionForm.querySelector(`[name="${param}"]`);
+ if (input) {
+ if (input.tagName === "SELECT") {
+ input.selectedIndex = 0;
+ } else {
+ input.value = "";
+ }
+ collectionForm.requestSubmit();
+ return;
+ }
+ }
+
+ // Header-level params (audience, time_period): rebuild URL and navigate
+ const url = new URL(window.location);
+
+ // Merge search form values into the URL so they aren't lost on navigation
+ if (collectionForm) {
+ const formData = new FormData(collectionForm);
+ for (const [key, val] of formData.entries()) {
+ if (!url.searchParams.has(key) && val !== "") {
+ url.searchParams.set(key, val);
+ }
+ }
+ }
+
+ if (param === "audience" && value) {
+ const defaults = ["visitors", "users"];
+ const current = url.searchParams.has("audience[]")
+ ? url.searchParams.getAll("audience[]")
+ : defaults;
+ const remaining = current.filter((v) => v !== value);
+ url.searchParams.delete("audience[]");
+ remaining.forEach((v) => url.searchParams.append("audience[]", v));
+ } else if (param === "time_period") {
+ const current = url.searchParams.get("time_period") || "past_month";
+ url.searchParams.set(
+ "time_period",
+ current === "all_time" ? "past_month" : "all_time",
+ );
+ } else {
+ url.searchParams.delete(param);
+ }
+ window.location = url.toString();
+ }
+}
diff --git a/app/frontend/javascript/controllers/header_form_controller.js b/app/frontend/javascript/controllers/header_form_controller.js
new file mode 100644
index 000000000..35c0432a2
--- /dev/null
+++ b/app/frontend/javascript/controllers/header_form_controller.js
@@ -0,0 +1,36 @@
+import { Controller } from "@hotwired/stimulus";
+
+// Connects to data-controller="header-form"
+// Merges search form (collection controller) values into the header form
+// before submitting, so search filters aren't lost when changing audience
+// or time period.
+export default class extends Controller {
+ submit() {
+ const collectionForm = document.querySelector(
+ '[data-controller="collection"]',
+ );
+
+ if (collectionForm) {
+ // Remove any previously injected hidden fields
+ this.element
+ .querySelectorAll("input[data-header-form-injected]")
+ .forEach((el) => el.remove());
+
+ const formData = new FormData(collectionForm);
+ for (const [key, val] of formData.entries()) {
+ // Skip params already handled by the header form (audience, time_period)
+ if (key === "audience[]" || key === "time_period") continue;
+ if (val === "") continue;
+
+ const hidden = document.createElement("input");
+ hidden.type = "hidden";
+ hidden.name = key;
+ hidden.value = val;
+ hidden.setAttribute("data-header-form-injected", "true");
+ this.element.appendChild(hidden);
+ }
+ }
+
+ this.element.requestSubmit();
+ }
+}
diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js
index 1c152e3cb..552df245b 100644
--- a/app/frontend/javascript/controllers/index.js
+++ b/app/frontend/javascript/controllers/index.js
@@ -9,6 +9,9 @@ application.register("anchor-highlight", AnchorHighlightController)
import AssetPickerController from "./asset_picker_controller"
application.register("asset-picker", AssetPickerController)
+import CheckboxSelectController from "./checkbox_select_controller"
+application.register("checkbox-select", CheckboxSelectController)
+
import AutosaveController from "./autosave_controller"
application.register("autosave", AutosaveController)
@@ -39,6 +42,12 @@ application.register("dropdown", DropdownController)
import FilePreviewController from "./file_preview_controller"
application.register("file-preview", FilePreviewController)
+import FilterChipController from "./filter_chip_controller"
+application.register("filter-chip", FilterChipController)
+
+import HeaderFormController from "./header_form_controller"
+application.register("header-form", HeaderFormController)
+
import InactiveToggleController from "./inactive_toggle_controller"
application.register("inactive-toggle", InactiveToggleController)
diff --git a/app/frontend/stylesheets/application.tailwind.css b/app/frontend/stylesheets/application.tailwind.css
index 5032fec7e..7097ba619 100644
--- a/app/frontend/stylesheets/application.tailwind.css
+++ b/app/frontend/stylesheets/application.tailwind.css
@@ -106,6 +106,11 @@ select.select-placeholder {
font-size: 0.875rem !important;
}
+/* Grey out date picker calendar icon */
+input[type="date"]::-webkit-calendar-picker-indicator {
+ opacity: 0.4;
+}
+
.is-active {
@apply text-primary border-b-2 border-primary font-bold bg-gray-100;
}
diff --git a/app/views/admin/ahoy_activities/_event_count.html.erb b/app/views/admin/ahoy_activities/_event_count.html.erb
new file mode 100644
index 000000000..4249e72a7
--- /dev/null
+++ b/app/views/admin/ahoy_activities/_event_count.html.erb
@@ -0,0 +1,7 @@
+
+
+
+
Events (<%= @count_display %>)
+
+
+
diff --git a/app/views/admin/ahoy_activities/_event_results.html.erb b/app/views/admin/ahoy_activities/_event_results.html.erb
new file mode 100644
index 000000000..3d19d36e6
--- /dev/null
+++ b/app/views/admin/ahoy_activities/_event_results.html.erb
@@ -0,0 +1,151 @@
+<%= turbo_stream.replace("event_count", partial: "event_count") %>
+<%= turbo_stream.replace("active_filters_subheader", partial: "admin/shared/active_filters_subheader") %>
+<%
+ sort_base = params.permit(:event_name, :resource_name, :visit_id, :props, :user_id, :time_period, :from, :to, :per_page, audience: []).to_h.symbolize_keys
+ sort_icon = ->(column) {
+ if @sort == column
+ @sort_direction == "asc" ? "fa-arrow-up" : "fa-arrow-down"
+ else
+ "fa-sort"
+ end
+ }
+ sort_link = ->(column, label) {
+ link_to admin_activities_events_path(sort_base.merge(sort: column, direction: (@sort == column && @sort_direction == "desc") ? "asc" : "desc", page: nil)),
+ data: { turbo_frame: "event_results" },
+ class: "inline-flex items-center gap-1 text-gray-700 hover:text-gray-900" do
+ concat label
+ concat content_tag(:i, "", class: "fa-solid #{sort_icon.call(column)} text-xs opacity-70")
+ end
+ }
+%>
+
+
+
+
+ | <%= sort_link.call("time", "Time") %> |
+ <%= sort_link.call("name", "Event name") %> |
+ Resource Title |
+ <%= sort_link.call("user", "User") %> |
+ Visit ID |
+ Properties |
+
+
+
+
+ <% if @events.any? %>
+ <% @events.each do |event| %>
+
+ |
+ <%= event.time.in_time_zone.strftime("%Y-%m-%d %H:%M %Z") %>
+ |
+
+
+ <%= event.name %>
+ |
+
+
+ <%= event.properties["resource_title"].presence || "—" %>
+ |
+
+
+ <% if event.user %>
+ <%= link_to event.user.full_name,
+ user_path(event.user),
+ class: "text-indigo-600 hover:underline" %>
+ <% else %>
+ Guest
+ <% end %>
+ |
+
+
+ <%= link_to event.visit_id,
+ admin_activities_visits_path(visit_id: event.visit_id, time_period: "all_time", audience: %w[visitors users staff]),
+ class: "text-indigo-600 hover:underline",
+ data: { turbo: false } %>
+ |
+
+
+ <% if event.properties.present? && event.properties.any? %>
+ <%= link_to admin_activities_event_path(event),
+ class: "cursor-default text-indigo-600 text-xs font-medium bg-indigo-50 px-2 py-0.5 rounded hover:bg-indigo-100" do %>
+ <%= event.properties.size %> <%= "prop".pluralize(event.properties.size) %>
+ <% end %>
+ <%
+ props = event.properties.dup
+ display_props = []
+ res_type = props["resource_type"]
+ res_id = props["resource_id"]
+ props.keys.select { |k| k.end_with?("_type") }.each do |type_key|
+ prefix = type_key.chomp("_type")
+ id_key = "#{prefix}_id"
+ if props.key?(id_key)
+ display_props << ["#{prefix} & ID", "#{props[type_key]} ##{props[id_key]}"]
+ props.delete(type_key)
+ props.delete(id_key)
+ end
+ end
+ rt_key = props.keys.find { |k| k.downcase == "resource_title" }
+ if rt_key
+ display_props.unshift(["resource_title", props.delete(rt_key)])
+ end
+ props.each do |key, value|
+ if value.is_a?(Hash)
+ value.each do |sub_key, sub_val|
+ label = "#{key}.#{sub_key}"
+ if sub_val.is_a?(Array) && sub_val.size == 2
+ display_props << [label, "#{sub_val[0]} → #{sub_val[1]}"]
+ else
+ display_props << [label, sub_val]
+ end
+ end
+ elsif value.is_a?(Array)
+ display_props << [key, value.join(", ")]
+ else
+ display_props << [key, value]
+ end
+ end
+ %>
+
+ <%
+ res_path = nil
+ if res_type.present? && res_id.present?
+ res_klass = res_type.safe_constantize
+ if res_klass && res_klass < ApplicationRecord
+ res_record = res_klass.find_by(id: res_id)
+ res_path = polymorphic_path(res_record) if res_record
+ end
+ end
+ %>
+ <% display_props.each do |key, value| %>
+
+ <%= key %>:
+ <% if key == "resource_title" && res_path %>
+ <%= link_to value.to_s.delete('"'), res_path, class: "text-indigo-200 underline hover:text-white", data: { turbo: false } %>
+ <% else %>
+ <%= value.nil? ? "nil" : value.to_s.delete('"') %>
+ <% end %>
+
+ <% end %>
+
+ <% else %>
+ none
+ <% end %>
+ |
+
+ <% end %>
+ <% else %>
+
+
+ No events found
+ |
+
+ <% end %>
+
+
+
+
+<% if @events.any? %>
+
+ <%= tailwind_paginate @events %>
+
+<% end %>
diff --git a/app/views/admin/ahoy_activities/_events_skeleton.html.erb b/app/views/admin/ahoy_activities/_events_skeleton.html.erb
new file mode 100644
index 000000000..8aa97aae8
--- /dev/null
+++ b/app/views/admin/ahoy_activities/_events_skeleton.html.erb
@@ -0,0 +1,26 @@
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+
+
+
+ <% 5.times do %>
+
+ |
+ |
+ |
+ |
+ |
+ |
+
+ <% end %>
+
+
+
diff --git a/app/views/admin/ahoy_activities/_visit_count.html.erb b/app/views/admin/ahoy_activities/_visit_count.html.erb
new file mode 100644
index 000000000..3c658ff6a
--- /dev/null
+++ b/app/views/admin/ahoy_activities/_visit_count.html.erb
@@ -0,0 +1,7 @@
+
+
+
+
Visits (<%= @count_display %>)
+
+
+
diff --git a/app/views/admin/ahoy_activities/_visit_results.html.erb b/app/views/admin/ahoy_activities/_visit_results.html.erb
new file mode 100644
index 000000000..e1e8163cd
--- /dev/null
+++ b/app/views/admin/ahoy_activities/_visit_results.html.erb
@@ -0,0 +1,99 @@
+<%= turbo_stream.replace("visit_count", partial: "visit_count") %>
+<%= turbo_stream.replace("active_filters_subheader", partial: "admin/shared/active_filters_subheader") %>
+<%
+ sort_base = params.permit(:user_id, :visit_id, :time_period, :from, :to, :per_page, audience: []).to_h.symbolize_keys
+ sort_icon = ->(column) {
+ if @sort == column
+ @sort_direction == "asc" ? "fa-arrow-up" : "fa-arrow-down"
+ else
+ "fa-sort"
+ end
+ }
+ sort_link = ->(column, label) {
+ link_to admin_activities_visits_path(sort_base.merge(sort: column, direction: (@sort == column && @sort_direction == "desc") ? "asc" : "desc", page: nil)),
+ data: { turbo_frame: "visit_results" },
+ class: "inline-flex items-center gap-1 text-gray-700 hover:text-gray-900" do
+ concat label
+ concat content_tag(:i, "", class: "fa-solid #{sort_icon.call(column)} text-xs opacity-70")
+ end
+ }
+%>
+
+
+
+
+ | Visit ID |
+ <%= sort_link.call("started_at", "Started") %> |
+ <%= sort_link.call("user", "User") %> |
+ IP |
+ <%= sort_link.call("events_count", "Events") %> |
+ <%= sort_link.call("duration", "Duration") %> |
+ User Agent |
+
+
+
+
+ <% if @visits.any? %>
+ <% @visits.each do |visit| %>
+
+ |
+ <%= visit.id %>
+ |
+
+
+ <%= visit.started_at.in_time_zone.strftime("%Y-%m-%d %H:%M %Z") %>
+ |
+
+
+ <% if visit.user %>
+ <%= link_to visit.user.full_name,
+ user_path(visit.user),
+ class: "text-indigo-600 hover:underline" %>
+ <% else %>
+ Guest
+ <% end %>
+ |
+
+
+ <%= visit.ip %>
+ |
+
+
+ <%= link_to visit.attributes["events_count"],
+ admin_activities_events_path(visit_id: visit.id, time_period: "all_time", audience: %w[visitors users staff]),
+ class: "text-indigo-600 hover:underline",
+ data: { turbo: false } %>
+ |
+
+
+ <% dm = visit.attributes["duration_minutes"] %>
+ <% if dm.nil? %>
+ —
+ <% elsif dm < 1 %>
+ < 1 min
+ <% else %>
+ <%= dm %> min
+ <% end %>
+ |
+
+
+ <%= visit.user_agent %>
+ |
+
+ <% end %>
+ <% else %>
+
+
+ No visits found
+ |
+
+ <% end %>
+
+
+
+
+<% if @visits.any? %>
+
+ <%= tailwind_paginate @visits %>
+
+<% end %>
diff --git a/app/views/admin/ahoy_activities/_visits_skeleton.html.erb b/app/views/admin/ahoy_activities/_visits_skeleton.html.erb
new file mode 100644
index 000000000..d7ee3207c
--- /dev/null
+++ b/app/views/admin/ahoy_activities/_visits_skeleton.html.erb
@@ -0,0 +1,26 @@
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+
+
+
+ <% 5.times do %>
+
+ |
+ |
+ |
+ |
+ |
+ |
+
+ <% end %>
+
+
+
diff --git a/app/views/admin/ahoy_activities/charts.html.erb b/app/views/admin/ahoy_activities/charts.html.erb
index 93e13251b..66d67ace4 100644
--- a/app/views/admin/ahoy_activities/charts.html.erb
+++ b/app/views/admin/ahoy_activities/charts.html.erb
@@ -3,24 +3,19 @@
Activities
<%= form_with url: admin_activities_charts_path, method: :get, local: true,
- class: "flex items-center gap-4" do %>
+ class: "flex items-center gap-4",
+ data: { controller: "header-form" } do %>
<%= render "admin/shared/audience_dropdown" %>
<%= select_tag :time_period,
options_for_select(
- [
- ['All time', 'all_time'],
- ['Past day', 'past_day'],
- ['Past week', 'past_week'],
- ['Past month', 'past_month'],
- ['Past year', 'past_year']
- ],
- params[:time_period] || 'past_month'
+ [["All time", "all_time"], ["Past day", "past_day"], ["Past week", "past_week"], ["Past month", "past_month"], ["Past year", "past_year"]],
+ params[:time_period] || "past_month"
),
class: "px-3 py-2 rounded-md border-gray-300 shadow-sm text-sm text-gray-700",
- onchange: "this.form.submit()" %>
+ data: { action: "header-form#submit" } %>
<% end %>
diff --git a/app/views/admin/ahoy_activities/index.html.erb b/app/views/admin/ahoy_activities/index.html.erb
index 40f817eb7..004e2c8ca 100644
--- a/app/views/admin/ahoy_activities/index.html.erb
+++ b/app/views/admin/ahoy_activities/index.html.erb
@@ -1,204 +1,141 @@
<% content_for(:page_bg_class, "admin-only bg-blue-100") %>
-
-
-
Activities
+
+
+
Activities
+ <%= form_with url: admin_activities_events_path, method: :get, local: true,
+ class: "flex items-center gap-4",
+ data: { controller: "header-form" } do %>
+ <%= render "admin/shared/audience_dropdown" %>
+
+
+
+ <%= select_tag :time_period,
+ options_for_select(
+ [["All time", "all_time"], ["Past day", "past_day"], ["Past week", "past_week"], ["Past month", "past_month"], ["Past year", "past_year"]],
+ params[:time_period] || "past_month"
+ ),
+ class: "px-3 py-2 rounded-md border-gray-300 shadow-sm text-sm text-gray-700",
+ data: { action: "header-form#submit" } %>
+
+ <% end %>
+
+
+ <%= render "admin/shared/activities_tabs" %>
+
+
+ <%= form_with url: admin_activities_events_path,
+ method: :get,
+ data: { controller: "collection",
+ turbo_frame: "event_results",
+ "form-type": "other",
+ lpignore: "true" },
+ autocomplete: "off",
+ class: "bg-white border border-gray-200 rounded-xl shadow-sm p-6 mb-6" do %>
+
+ <% # Carry audience + time_period through the turbo frame form %>
+ <% Array(params[:audience]).reject(&:blank?).each do |aud| %>
+ <%= hidden_field_tag "audience[]", aud %>
+ <% end %>
+ <%= hidden_field_tag :time_period, params[:time_period] || "past_month" %>
+
+
+
+
+
+
+ <%= date_field_tag :from,
+ params[:from],
+ placeholder: "Start date",
+ class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 #{params[:from].blank? ? 'text-gray-400' : 'text-gray-900'}" %>
+
+
+
+
+
+ <%= date_field_tag :to,
+ params[:to],
+ placeholder: "End date",
+ class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 #{params[:to].blank? ? 'text-gray-400' : 'text-gray-900'}" %>
+
-
- <%= @events.count %> events
-
+
+
+
+ <%= text_field_tag :event_name,
+ params[:event_name],
+ placeholder: "view.workshop",
+ autocomplete: "off",
+ data: { lpignore: "true" },
+ class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 placeholder:text-gray-400" %>
- <%= render "admin/shared/activities_tabs" %>
-
- <%= render "admin/shared/active_filters_subheader" %>
-
-
-
- <%= form_with url: request.path, method: :get, local: true do %>
-
-
-
-
-
-
- <%= text_field_tag :name,
- params[:name],
- placeholder: "e.g. Viewed Workshop",
- class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500" %>
-
-
-
-
-
- <%= select_tag :user_id,
- options_from_collection_for_select(
- User.where(id: Ahoy::Visit.select(:user_id).distinct).order(:first_name, :last_name),
- :id,
- :full_name,
- params[:user_id]
- ),
- include_blank: "All Users",
- class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500" %>
-
-
-
-
- <%= render "admin/shared/audience_dropdown", auto_submit: false, stacked: true %>
-
-
-
-
-
- <%= select_tag :time_period,
- options_for_select(
- [["All time", "all_time"], ["Past day", "past_day"], ["Past week", "past_week"], ["Past month", "past_month"], ["Past year", "past_year"]],
- params[:time_period] || "past_month"
- ),
- class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500" %>
-
-
-
-
-
- <%= date_field_tag :from,
- params[:from],
- class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm" %>
-
-
-
-
-
- <%= date_field_tag :to,
- params[:to],
- class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm" %>
-
-
-
-
-
-
- Quick:
- <% safe_params = params.slice(:name, :user_id, :from, :to).to_unsafe_h %>
- <%= link_to "24h",
- url_for(safe_params.merge(from: 1.day.ago.to_date)),
- class: "text-indigo-600 hover:underline" %>
-
- <%= link_to "7d",
- url_for(safe_params.merge(from: 7.days.ago.to_date)),
- class: "text-indigo-600 hover:underline" %>
-
- <%= link_to "30d",
- url_for(safe_params.merge(from: 30.days.ago.to_date)),
- class: "text-indigo-600 hover:underline" %>
-
-
-
- <%= submit_tag "Filter",
- class: "bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 transition" %>
-
-
- <% end %>
+
+
+
+ <%= text_field_tag :resource_name,
+ params[:resource_name],
+ placeholder: "My Workshop",
+ autocomplete: "off",
+ class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 placeholder:text-gray-400" %>
-
-
-
-
-
- | Time |
- Event |
- Resource |
- User |
- Visit ID |
- Properties |
-
-
-
-
- <% @events.each do |event| %>
-
- |
- <%= event.time.in_time_zone.strftime("%Y-%m-%d %H:%M %Z") %>
- |
-
-
- <%= event.name %>
- |
-
-
- <%= event.properties["resource_title"].presence || "—" %>
- |
-
-
- <% if event.user %>
- <%= link_to event.user.full_name,
- user_path(event.user),
- class: "text-indigo-600 hover:underline" %>
- <% else %>
- Guest
- <% end %>
- |
-
-
- <%= event.visit_id %>
- |
-
-
- <% if event.properties.present? && event.properties.any? %>
- <%= link_to admin_activities_event_path(event),
- class: "cursor-default text-indigo-600 text-xs font-medium bg-indigo-50 px-2 py-0.5 rounded hover:bg-indigo-100" do %>
- <%= event.properties.size %> <%= "prop".pluralize(event.properties.size) %>
- <% end %>
- <%
- props = event.properties.dup
- display_props = []
- # Combine polymorphic _type/_id pairs
- props.keys.select { |k| k.end_with?("_type") }.each do |type_key|
- prefix = type_key.chomp("_type")
- id_key = "#{prefix}_id"
- if props.key?(id_key)
- display_props << [prefix, "#{props[type_key]}.find(#{props[id_key]})"]
- props.delete(type_key)
- props.delete(id_key)
- end
- end
- # Show resource_title first, then remaining props
- if props.key?("resource_title")
- display_props.unshift(["resource_title", props.delete("resource_title")])
- end
- props.each do |key, value|
- display_props << [key, value.is_a?(Hash) ? value.to_json : value]
- end
- %>
-
- <% display_props.each do |key, value| %>
- <%= key %>: <%= value.nil? ? "nil" : value.to_s.delete('"') %>
- <% end %>
-
- <% else %>
- none
- <% end %>
- |
-
- <% end %>
-
-
+
+
+
+ <%= text_field_tag :props,
+ params[:props],
+ placeholder: "props...",
+ autocomplete: "off",
+ class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 placeholder:text-gray-400" %>
-
-
- <%= tailwind_paginate @events %>
+
+
+
+ <%= select_tag :user_id,
+ options_from_collection_for_select(
+ User.where(id: Ahoy::Visit.select(:user_id).distinct).order(:first_name, :last_name),
+ :id,
+ :full_name,
+ params[:user_id]
+ ),
+ include_blank: "Select user",
+ class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 #{params[:user_id].blank? ? 'text-gray-400' : 'text-gray-900'}" %>
+
+
+
+
+ <%= number_field_tag :visit_id,
+ params[:visit_id],
+ placeholder: "42",
+ autocomplete: "off",
+ class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 placeholder:text-gray-400" %>
+
+
+
+
+
+ <%= link_to "Clear filters",
+ admin_activities_events_path,
+ class: "btn btn-utility w-full text-center px-3 py-2",
+ data: { action: "collection#clearAndSubmit" } %>
+
+
+
+
+ <% end %>
+
+ <%= render "admin/shared/active_filters_subheader" %>
+
+
+ <%= render "event_count" %>
+
+
+ <% result_src = admin_activities_events_path + "?" + request.query_string %>
+ <%= turbo_frame_tag "event_results", src: result_src do %>
+ <%= render "events_skeleton" %>
+ <% end %>
diff --git a/app/views/admin/ahoy_activities/index_lazy.html.erb b/app/views/admin/ahoy_activities/index_lazy.html.erb
new file mode 100644
index 000000000..b9b607ba3
--- /dev/null
+++ b/app/views/admin/ahoy_activities/index_lazy.html.erb
@@ -0,0 +1,3 @@
+<%= turbo_frame_tag "event_results" do %>
+ <%= render "event_results" %>
+<% end %>
diff --git a/app/views/admin/ahoy_activities/visits.html.erb b/app/views/admin/ahoy_activities/visits.html.erb
index 5c3af90ab..a5e33e133 100644
--- a/app/views/admin/ahoy_activities/visits.html.erb
+++ b/app/views/admin/ahoy_activities/visits.html.erb
@@ -1,153 +1,110 @@
<% content_for(:page_bg_class, "admin-only bg-blue-100") %>
-
-
Activities
+
+
+
Activities
+ <%= form_with url: admin_activities_visits_path, method: :get, local: true,
+ class: "flex items-center gap-4",
+ data: { controller: "header-form" } do %>
+ <%= render "admin/shared/audience_dropdown" %>
+
+
+
+ <%= select_tag :time_period,
+ options_for_select(
+ [["All time", "all_time"], ["Past day", "past_day"], ["Past week", "past_week"], ["Past month", "past_month"], ["Past year", "past_year"]],
+ params[:time_period] || "past_month"
+ ),
+ class: "px-3 py-2 rounded-md border-gray-300 shadow-sm text-sm text-gray-700",
+ data: { action: "header-form#submit" } %>
+
+ <% end %>
+
+
+ <%= render "admin/shared/activities_tabs" %>
+
+
+ <%= form_with url: admin_activities_visits_path,
+ method: :get,
+ data: { controller: "collection",
+ turbo_frame: "visit_results",
+ "form-type": "other",
+ lpignore: "true" },
+ autocomplete: "off",
+ class: "bg-white border border-gray-200 rounded-xl shadow-sm p-6 mb-6" do %>
+
+ <% # Carry audience + time_period through the turbo frame form %>
+ <% Array(params[:audience]).reject(&:blank?).each do |aud| %>
+ <%= hidden_field_tag "audience[]", aud %>
+ <% end %>
+ <%= hidden_field_tag :time_period, params[:time_period] || "past_month" %>
+
+
+
+
+
+
+ <%= date_field_tag :from,
+ params[:from],
+ placeholder: "Start date",
+ class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 #{params[:from].blank? ? 'text-gray-400' : 'text-gray-900'}" %>
+
+
+
+
+
+ <%= date_field_tag :to,
+ params[:to],
+ placeholder: "End date",
+ class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 #{params[:to].blank? ? 'text-gray-400' : 'text-gray-900'}" %>
- <%= render "admin/shared/activities_tabs" %>
-
- <%= render "admin/shared/active_filters_subheader" %>
-
-
-
- <%= form_with url: request.path, method: :get, local: true do %>
-
-
-
-
-
-
- <%= select_tag :user_id,
- options_from_collection_for_select(
- User.where(id: Ahoy::Visit.select(:user_id).distinct).order(:first_name, :last_name),
- :id,
- :full_name,
- params[:user_id]
- ),
- include_blank: "All Users",
- class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500" %>
-
-
-
-
- <%= render "admin/shared/audience_dropdown", auto_submit: false, stacked: true %>
-
-
-
-
-
- <%= select_tag :time_period,
- options_for_select(
- [["All time", "all_time"], ["Past day", "past_day"], ["Past week", "past_week"], ["Past month", "past_month"], ["Past year", "past_year"]],
- params[:time_period] || "past_month"
- ),
- class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500" %>
-
-
-
-
-
- <%= date_field_tag :from, params[:from],
- class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm" %>
-
-
-
-
-
- <%= date_field_tag :to, params[:to],
- class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm" %>
-
-
-
-
-
-
- Quick:
- <% safe_params = params.slice(:user_id, :from, :to).to_unsafe_h %>
- <%= link_to "24h",
- url_for(safe_params.merge(from: 1.day.ago.to_date)),
- class: "text-indigo-600 hover:underline" %>
-
- <%= link_to "7d",
- url_for(safe_params.merge(from: 7.days.ago.to_date)),
- class: "text-indigo-600 hover:underline" %>
-
- <%= link_to "30d",
- url_for(safe_params.merge(from: 30.days.ago.to_date)),
- class: "text-indigo-600 hover:underline" %>
-
-
-
- <%= submit_tag "Filter",
- class: "bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 transition" %>
-
-
- <% end %>
+
+
+
+ <%= select_tag :user_id,
+ options_from_collection_for_select(
+ User.where(id: Ahoy::Visit.select(:user_id).distinct).order(:first_name, :last_name),
+ :id,
+ :full_name,
+ params[:user_id]
+ ),
+ include_blank: "Select user",
+ class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 #{params[:user_id].blank? ? 'text-gray-400' : 'text-gray-900'}" %>
-
-
-
-
-
- | ID |
- Started |
- User |
- IP |
- Events |
- User Agent |
-
-
-
-
- <% @visits.each do |visit| %>
-
- |
- <%= visit.id %>
- |
-
-
- <%= visit.started_at.in_time_zone.strftime("%Y-%m-%d %H:%M %Z") %>
- |
-
-
- <% if visit.user %>
- <%= link_to visit.user.full_name,
- user_path(visit.user),
- class: "text-indigo-600 hover:underline" %>
- <% else %>
- Guest
- <% end %>
- |
-
-
- <%= visit.ip %>
- |
-
-
- <%= link_to visit.attributes["events_count"],
- admin_activities_events_path(visit_id: visit.id) %>
- |
-
-
- <%= visit.user_agent %>
- |
-
- <% end %>
-
-
+
+
+
+ <%= number_field_tag :visit_id,
+ params[:visit_id],
+ placeholder: "42",
+ autocomplete: "off",
+ class: "w-full px-3 py-2 border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 placeholder:text-gray-400" %>
-
- <%= tailwind_paginate @visits %>
+
+
+
+ <%= link_to "Clear filters",
+ admin_activities_visits_path,
+ class: "btn btn-utility w-full text-center px-3 py-2",
+ data: { action: "collection#clearAndSubmit" } %>
+
+
+
+ <% end %>
+
+ <%= render "admin/shared/active_filters_subheader" %>
+
+
+ <%= render "visit_count" %>
+
+
+ <% result_src = admin_activities_visits_path + "?" + request.query_string %>
+ <%= turbo_frame_tag "visit_results", src: result_src do %>
+ <%= render "visits_skeleton" %>
+ <% end %>
diff --git a/app/views/admin/ahoy_activities/visits_lazy.html.erb b/app/views/admin/ahoy_activities/visits_lazy.html.erb
new file mode 100644
index 000000000..ef168388c
--- /dev/null
+++ b/app/views/admin/ahoy_activities/visits_lazy.html.erb
@@ -0,0 +1,3 @@
+<%= turbo_frame_tag "visit_results" do %>
+ <%= render "visit_results" %>
+<% end %>
diff --git a/app/views/admin/analytics/index.html.erb b/app/views/admin/analytics/index.html.erb
index 7b0ab7921..0dfc6b33a 100644
--- a/app/views/admin/analytics/index.html.erb
+++ b/app/views/admin/analytics/index.html.erb
@@ -3,22 +3,21 @@
Activities
- <%= form_with url: admin_activities_counts_path, method: :get, local: true, class: "flex items-center gap-4" do |f| %>
+ <%= form_with url: admin_activities_counts_path, method: :get, local: true,
+ class: "flex items-center gap-4",
+ data: { controller: "header-form" } do |f| %>
<%= render "admin/shared/audience_dropdown" %>
<%= f.label :time_period, "Time period:", class: "text-sm font-medium text-gray-700" %>
<%= f.select :time_period,
- options_for_select([
- ['All time', 'all_time'],
- ['Past day', 'past_day'],
- ['Past week', 'past_week'],
- ['Past month', 'past_month'],
- ['Past year', 'past_year']
- ], params[:time_period] || 'past_month'),
+ options_for_select(
+ [["All time", "all_time"], ["Past day", "past_day"], ["Past week", "past_week"], ["Past month", "past_month"], ["Past year", "past_year"]],
+ params[:time_period] || "past_month"
+ ),
{},
class: "px-3 py-2 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm text-gray-700",
- onchange: "this.form.submit()" %>
+ data: { action: "header-form#submit" } %>
<% end %>
diff --git a/app/views/admin/shared/_active_filters_subheader.html.erb b/app/views/admin/shared/_active_filters_subheader.html.erb
index 215792cf5..8d148518f 100644
--- a/app/views/admin/shared/_active_filters_subheader.html.erb
+++ b/app/views/admin/shared/_active_filters_subheader.html.erb
@@ -1,6 +1,8 @@
+