Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
d93a62b
Add hide toggles to the admin activities timeline
maebeale Sep 1, 2026
7addb5c
Keep the activities filter form scoped to a person/user/record
maebeale Sep 1, 2026
c8e2169
Merge activity+resource column with action chips and edit links
maebeale Sep 1, 2026
7797cf4
Match plain-language action words in the activity search
maebeale Sep 1, 2026
24a9c68
Collapse activity change diffs onto one line
maebeale Sep 1, 2026
91bb0e5
Narrow the activity User and Visit ID columns
maebeale Sep 1, 2026
298d0fd
Make comment and communication activity rows read as notes
maebeale Sep 1, 2026
5c3d60a
Compose activity resource labels and link the whole Activity cell
maebeale Sep 1, 2026
6939738
Link the whole communication Activity cell to the notification
maebeale Sep 1, 2026
5a8ff7f
Tidy communication row: inline body, no cell-level notification link
maebeale Sep 1, 2026
e64354e
Truncate long comment and email bodies with hover-to-see-full
maebeale Sep 1, 2026
9590ce1
Align Details rows: field name and change on one line, matched sizes
maebeale Sep 1, 2026
21f6028
Make the Details cell clickable to the resource, unstyled
maebeale Sep 1, 2026
4e3f748
Make the communication Details cell clickable, unstyled
maebeale Sep 1, 2026
0242b1e
Color the communication name purple to match the other activity rows
maebeale Sep 1, 2026
4d74011
Link the communication Activity cell to the notification
maebeale Sep 1, 2026
0e7dd79
Label a comment's body 'Comment:' in the activity Details
maebeale Sep 1, 2026
ced81fe
Merge Activity and Details into one timeline column
maebeale Sep 1, 2026
3931bd5
Refine timeline headline: resource inline, aligned chips, Body label
maebeale Sep 1, 2026
12c3550
Lead the activity headline with the record, type as grey suffix
maebeale Sep 1, 2026
d63add2
Link the activity Visit ID to the filtered visits index
maebeale Sep 1, 2026
d94b3dd
Add Features & tips entry for the redesigned activities timeline
maebeale Sep 1, 2026
5b23d77
Truncate activity comment/email bodies at 150 chars, not 50
maebeale Sep 1, 2026
cc87365
Tint comment activity rows with the comments domain color
maebeale Sep 1, 2026
ad6b212
Show an affiliation's date span in the activity label
maebeale Sep 1, 2026
c032145
Compose scholarship, CE, and event-registration activity labels
maebeale Sep 1, 2026
2e844ef
Trim redundant rows from auth event details
maebeale Sep 1, 2026
40ed136
Update people/edit History-link spec for the hidden-noise params
maebeale Sep 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 37 additions & 14 deletions app/controllers/admin/ahoy_activities_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,29 @@ def index
*prefixes.map { |p| "#{p}.%" })
end

# Filter by event name. Split on any non-alphanumeric run so hyphens (and
# commas, dots, spaces) are interchangeable separators and each token must
# match β€” e.g. "account-auth" finds "auth.account_deactivated".
# Hide toggles: exclude whole categories (account, interactions) in bulk so
# the timeline can be pared down to changelogs and communications.
hidden = hidden_name_patterns
if hidden.present?
scope = scope.where.not(hidden.map { "ahoy_events.name LIKE ?" }.join(" OR "), *hidden)
end

# One search box spans the activity name and the resource title. Split on any
# non-alphanumeric run so hyphens (and commas, dots, spaces) are interchangeable
# separators, and each token must match one of them β€” e.g. "account-auth" finds
# "auth.account_deactivated" by name, "feelings" finds a "Feelings Collage"
# resource by title. A plain-language chip word ("new", "edit") also matches its
# raw action prefix, so search matches what the reader sees in the row.
if params[:event_name].present?
params[:event_name].split(/[^a-z0-9]+/i).reject(&:blank?).each do |token|
scope = scope.where("ahoy_events.name LIKE ?", "%#{Ahoy::Event.sanitize_sql_like(token)}%")
like = "%#{Ahoy::Event.sanitize_sql_like(token)}%"
clauses = [ "ahoy_events.name LIKE ?", "LOWER(ahoy_events.properties->>'$.resource_title') LIKE LOWER(?)" ]
binds = [ like, like ]
Ahoy::EventDecorator.action_keys_for_label(token).each do |action|
clauses << "ahoy_events.name LIKE ?"
binds << "#{Ahoy::Event.sanitize_sql_like(action)}.%"
end
scope = scope.where(clauses.join(" OR "), *binds)
end
end

Expand Down Expand Up @@ -63,15 +80,6 @@ def index
scope = scope.where(visit_id: params[:visit_id])
end

# Filter by resource title (the human name captured in the event's properties)
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

# Filter by props (full-text search across properties JSON)
if params[:props].present?
term = Ahoy::Event.sanitize_sql_like(params[:props])
Expand Down Expand Up @@ -218,7 +226,7 @@ def charts
# A visit_id filter excludes communications β€” they have no visit to belong to.
def person_communications
email = @person.communications_email
return Notification.none if email.blank? || params[:visit_id].present?
return Notification.none if email.blank? || params[:visit_id].present? || hide_communications?

scope = Notification.email(email).includes(:noticeable, sender: :person).order(created_at: :desc)
scope = scope.where(created_at: time_range) if time_range.present?
Expand Down Expand Up @@ -729,6 +737,21 @@ def selected_audiences
@selected_audiences ||= Array(params[:audience]).reject(&:blank?).presence || %w[visitors users]
end

def hidden_name_patterns
patterns = []
patterns.concat(Ahoy::Event::ACCOUNT_NAME_PATTERNS) if param_true?(params[:hide_account])
patterns.concat(Ahoy::Event::INTERACTION_NAME_PATTERNS) if param_true?(params[:hide_interactions])
patterns
end

def hide_communications?
param_true?(params[:hide_communications])
end

def param_true?(value)
ActiveModel::Type::Boolean.new.cast(value)
end

def scoped_visits
scope = Ahoy::Visit.all
scope = scope.where(started_at: time_range) if time_range
Expand Down
177 changes: 176 additions & 1 deletion app/decorators/ahoy/event_decorator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,119 @@ class EventDecorator < ApplicationDecorator
# Already surfaced in their own table columns, so redundant inside the details cell.
REDUNDANT_KEYS = %w[resource_type resource_id resource_title].freeze

# An auth event names the acting user twice (record_* duplicates resource_*) and
# carries an updated_by that's always that same actor β€” all noise beside the
# headline, so leave them out of a login's details.
AUTH_REDUNDANT_KEYS = %w[record_id record_type updated_by_id].freeze

# Fields that title the record they belong to, in the order they should lead.
HEADING_KEYS = %w[topic title name subject].freeze

# Plain-language chips for the raw "action.resource" event name, so a
# non-technical reader sees "New" / "Edit" instead of "create." / "update.".
# Colored ones flag record changes; everything else reads gray. Class literals
# live here (decorators are Tailwind-scanned) so the chip generates.
ACTION_CHIPS = {
"create" => { label: "New", classes: "bg-blue-100 text-blue-800" },
"update" => { label: "Edit", classes: "bg-green-100 text-green-800" },
"destroy" => { label: "Delete", classes: "bg-red-100 text-red-800" },
"autochange" => { label: "Auto", classes: "bg-gray-100 text-gray-600" },
"view" => { label: "View", classes: "bg-gray-100 text-gray-600" },
"print" => { label: "Print", classes: "bg-gray-100 text-gray-600" },
"download" => { label: "Download", classes: "bg-gray-100 text-gray-600" },
"search" => { label: "Search", classes: "bg-gray-100 text-gray-600" },
"search_zero" => { label: "Search", classes: "bg-gray-100 text-gray-600" },
"filter" => { label: "Filter", classes: "bg-gray-100 text-gray-600" },
"auth" => { label: "Account", classes: "bg-gray-100 text-gray-600" },
"dedupe" => { label: "Merge", classes: "bg-gray-100 text-gray-600" }
}.freeze
DEFAULT_CHIP_CLASSES = "bg-gray-100 text-gray-600".freeze

# The raw action prefixes a plain-language chip word maps to, so the activity
# search can match what the reader sees: "new" finds create events, "search"
# finds both search and search_zero. Empty for a word that isn't a chip label.
def self.action_keys_for_label(term)
ACTION_CHIPS.select { |_action, chip| chip[:label].casecmp?(term) }.keys
end

def comment?
object.resource_type == "Comment"
end

# { label:, classes: } for the leading action chip.
def activity_chip
ACTION_CHIPS[action_key] || { label: action_key.humanize, classes: DEFAULT_CHIP_CLASSES }
end

# The resource half of the event name, humanized: "workshop_variation" reads
# "Workshop variation", "account_deactivated" reads "Account deactivated".
def activity_resource_label
object.name.to_s.split(".", 2)[1].to_s.humanize
end

# The event's own resource, linked to its edit page so an admin can jump
# straight there from the timeline. A comment instead points at the record it
# was left on (the affiliation, scholarship, profile) β€” that's what an admin
# reads it by, not the comment's own id. Title comes from properties; the
# record resolves through the page cache (Analytics::EventReferenceLoader).
# Returns nil when there's no title; :path is nil when the record is gone or
# has no editable route (rendered as plain text).
def resource_link
return commentable_link if comment&.commentable

record = find_referenced_record(object.resource_type, object.resource_id)
custom = custom_resource_link(record)
return custom if custom

title = properties_hash["resource_title"]
return nil if title.blank?

{ text: title, path: edit_path_for(record) }
end

# The record a comment was left on, labeled and linked. Reuses the same
# record-specific labels as direct events, falling back to the shared comment
# feed helper (CommentsHelper) so the two never drift.
def commentable_link
commentable = comment.commentable
custom_resource_link(commentable) ||
{ text: h.commentable_label(commentable), path: h.record_edit_path(commentable) }
end

def comment_flagged?
comment&.flagged? || false
end

# A comment's topic + body, read from the record so it shows in Details
# regardless of how the body was captured in properties. nil for non-comments
# and for a comment with nothing to show (e.g. a since-deleted record).
def comment_note
return nil unless comment

note = { topic: comment.topic.presence, body: comment.body.presence }
note.values.any? ? note : nil
end

# Everything the dedicated columns don't already show.
def extra_properties
properties_hash.except(*REDUNDANT_KEYS)
keys = auth? ? REDUNDANT_KEYS + AUTH_REDUNDANT_KEYS : REDUNDANT_KEYS
properties_hash.except(*keys)
end

def auth?
object.name.to_s.start_with?("auth.")
end

def extra_details?
extra_properties.present?
end

# Whether the Details block has anything to show (so the merged Activity cell
# can skip the empty-dash placeholder).
def details?
comment_note.present? || extra_details?
end

def changes?
change_diffs.is_a?(Hash) && change_diffs.present?
end
Expand Down Expand Up @@ -52,6 +153,80 @@ def detail_rows

private

def action_key
object.name.to_s.split(".", 2).first.to_s
end

# The Comment this event is about, from the page cache (no extra query); nil
# for non-comment events.
def comment
return nil unless object.resource_type == "Comment"

@comment ||= find_referenced_record("Comment", object.resource_id)
end

# Record types that read better as a composed label than as their raw
# resource_title. Returns a { text:, path: } link, or nil to use the default
# (resource_title for events, commentable_label for comments). A payment
# points at what it's allocated to, not the payment row.
def custom_resource_link(record)
case record
when Affiliation
{ text: [ record.title.presence, record.organization&.name, affiliation_dates(record) ].compact.join(" Β· "),
path: edit_path_for(record) }
when EventRegistration
span = [ record.event&.title, record.event&.start_date&.strftime("%b'%y") ].compact.join(" Β· ")
{ text: span.present? ? "Registration: #{span}" : "Registration", path: edit_path_for(record) }
when Scholarship
headline = [ h.dollars_from_cents(record.amount_cents), record.grant&.name ].compact.join(" ")
{ text: [ headline.presence, record.grant&.funder_name ].compact.join(" Β· "),
path: edit_path_for(record) }
when ContinuingEducationRegistration
{ text: [ ce_hours_label(record), record.professional_license&.name ].compact.join(" Β· "),
path: edit_path_for(record) }
when Payment
payment_allocation_link(record)
end
end

# "13 hours" / "1 hour", dropping a trailing .0 on whole-number hours.
def ce_hours_label(ce_registration)
hours = ce_registration.hours
hours = hours.to_i if hours == hours.to_i
h.pluralize(hours, "hour")
end

# An affiliation's span: "Aug'25 - Feb'26", or "Aug'25 - present" while active.
# nil when it has no start date to anchor the range.
def affiliation_dates(affiliation)
return nil unless affiliation.start_date

finish = affiliation.end_date&.strftime("%b'%y") || "present"
"#{affiliation.start_date.strftime("%b'%y")} - #{finish}"
end

# A payment reads as what it paid for β€” its allocations' targets (an event
# registration, a scholarship). nil (default label) when nothing's allocated.
def payment_allocation_link(payment)
allocatables = payment.allocations.map(&:allocatable).compact
return nil if allocatables.empty?

descriptor = h.allocatable_descriptor(allocatables.first)
text = descriptor[:title]
text = "#{text} +#{allocatables.size - 1} more" if allocatables.size > 1
{ text: text, path: descriptor[:path] }
end

# Prefer the record's edit page (the point of the link), falling back to its
# show page, then to nothing when neither route exists.
def edit_path_for(record)
return nil unless record

h.edit_polymorphic_path(record)
rescue StandardError
show_path_for(record)
end

def properties_hash
object.properties || {}
end
Expand Down
12 changes: 12 additions & 0 deletions app/models/ahoy/event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@ class Ahoy::Event < ApplicationRecord
# browsing belongs.
NON_MUTATION_PREFIXES = %w[view print search filter download].freeze

# Event-name patterns the activities index can toggle off in bulk.
#
# Account = the user/account lifecycle: every auth.* callback (login, password
# reset, email change, lock, admin grant, account setup/delete, …) plus
# mutations of the User record itself, so one "account" toggle sweeps all user
# churn out of a person's timeline.
#
# Interaction = read/browse noise (NON_MUTATION_PREFIXES plus zero-result
# searches).
ACCOUNT_NAME_PATTERNS = [ "auth.%", "create.user", "update.user", "destroy.user" ].freeze
INTERACTION_NAME_PATTERNS = (NON_MUTATION_PREFIXES + %w[search_zero]).map { |prefix| "#{prefix}.%" }.freeze

scope :mutations, -> {
NON_MUTATION_PREFIXES.reduce(all) { |scope, prefix| scope.where.not(arel_table[:name].matches("#{prefix}.%")) }
}
Expand Down
10 changes: 9 additions & 1 deletion app/services/analytics/event_reference_loader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def records
private

def load_records
pairs = @events.flat_map { |event| references_in(event.properties || {}) }.uniq
pairs = @events.flat_map { |event| primary_pairs(event) + references_in(event.properties || {}) }.uniq
pairs.group_by(&:first).each_with_object({}) do |(type, type_pairs), map|
klass = type.safe_constantize
next unless klass.respond_to?(:where) && klass < ApplicationRecord
Expand All @@ -47,6 +47,14 @@ def load_records
{}
end

# The event's own resource (its resource_type/resource_id columns), so the
# timeline can link the resource title to the record without a per-row query.
def primary_pairs(event)
return [] if event.resource_type.blank? || event.resource_id.blank?

[ [ event.resource_type.to_s, event.resource_id ] ]
end

def references_in(value)
case value
when Hash
Expand Down
30 changes: 30 additions & 0 deletions app/views/admin/ahoy_activities/_activity_cell.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<%# Merged Activity cell: the action chip on the left, then the name, the grey
resource sublabel, and the event's Details stacked in one column indented
under the name. The caller marks the cell `group` and overlays a link to the
resource, so the name underlines on hover and the whole cell navigates. %>
<div class="flex items-start gap-2" title="<%= event.name %>">
<span class="mt-0.5 flex w-24 shrink-0 justify-end">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold tracking-wide uppercase <%= chip[:classes] %>">
<%= chip[:label] %>
</span>
</span>
<div class="min-w-0">
<div class="flex flex-wrap items-baseline gap-x-1.5">
<% if resource %>
<span class="font-medium text-indigo-600 group-hover:underline"><%= resource[:text] %></span>
<span class="text-gray-300">&middot;</span>
<span class="text-xs text-gray-500"><%= activity.activity_resource_label %></span>
<% else %>
<span class="font-medium text-indigo-600 group-hover:underline"><%= activity.activity_resource_label %></span>
<% end %>
<% if activity.comment_flagged? %>
<i class="fa-solid fa-flag text-orange-500" title="Flagged"></i>
<% end %>
</div>
<% if activity.details? %>
<div class="pt-1 text-gray-500">
<%= render "admin/ahoy_activities/event_details", activity: activity %>
</div>
<% end %>
</div>
</div>
Loading