Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 24 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,9 @@ follow this). Match the existing pattern:
- The controller `index` branches on `turbo_frame_request?`: the frame request
builds the filtered/paginated scope and renders the results view; otherwise it
renders the full `index`.
- **The skeleton request runs none of the frame's queries.** Build scopes, counts,
and totals only inside the `turbo_frame_request?` branch; a `@total = scope.count`
above the branch runs on both requests, so the page queries twice.
- **Name the frame-response view after its Turbo frame tag id** β€” the
`turbo_frame_tag` id, the controller render target, and the view filename are
one shared `<resource>_results` token (e.g. `turbo_frame_tag :grants_results` ←
Expand Down Expand Up @@ -347,6 +350,27 @@ follow this). Match the existing pattern:
`data-turbo-permanent` (plus an `id`) on any TomSelect wrapper so the morph
doesn't strip its widget. Workshop logs is the reference.

## Turbo frames & streams

Rules from past PR reviews:

- **One lazy request per page.** When several parts of a page load lazily (badges,
counts, panels), load them from one action, not one frame per part β€” each lazy
request holds a Puma thread, so the user's next click queues behind them. Wrap
the parts in one frame (admin home `action_buttons` is the reference).
- **Keep a frame response a pure frame swap β€” no `<turbo-stream>` tags inside it.**
If a frame's response needs to update elements outside the frame, widen the
frame to contain them. Some older results views still tuck streams into their
frame; don't copy them.
- **Answer stream requests with a `*.turbo_stream.erb` template** via
`respond_to { |format| format.turbo_stream }`, not inline `render turbo_stream:`
in the controller β€” the template keeps markup in views and out of actions.
- **Don't set `turbo: false` to make something work β€” find the root cause.**
Links that leave a frame use `data: { turbo_frame: "_top" }` (or a `target: "_top"`
frame); keep `turbo: false` for downloads, new tabs, and pages that truly need a
full load. Turbo already disables a form's submitter while it submits, so a Turbo
form needs no double-submit guard (`submit_once` is for forms that stay non-Turbo).

## Features & tips page (`/features`)

The login-gated **Features & tips** page lists shipped, user-facing features
Expand Down
24 changes: 24 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,9 @@ follow this). Match the existing pattern:
- The controller `index` branches on `turbo_frame_request?`: the frame request
builds the filtered/paginated scope and renders the results view; otherwise it
renders the full `index`.
- **The skeleton request runs none of the frame's queries.** Build scopes, counts,
and totals only inside the `turbo_frame_request?` branch; a `@total = scope.count`
above the branch runs on both requests, so the page queries twice.
- **Name the frame-response view after its Turbo frame tag id** β€” the
`turbo_frame_tag` id, the controller render target, and the view filename are
one shared `<resource>_results` token (e.g. `turbo_frame_tag :grants_results` ←
Expand Down Expand Up @@ -357,6 +360,27 @@ follow this). Match the existing pattern:
`data-turbo-permanent` (plus an `id`) on any TomSelect wrapper so the morph
doesn't strip its widget. Workshop logs is the reference.

## Turbo frames & streams

Rules from past PR reviews:

- **One lazy request per page.** When several parts of a page load lazily (badges,
counts, panels), load them from one action, not one frame per part β€” each lazy
request holds a Puma thread, so the user's next click queues behind them. Wrap
the parts in one frame (admin home `action_buttons` is the reference).
- **Keep a frame response a pure frame swap β€” no `<turbo-stream>` tags inside it.**
If a frame's response needs to update elements outside the frame, widen the
frame to contain them. Some older results views still tuck streams into their
frame; don't copy them.
- **Answer stream requests with a `*.turbo_stream.erb` template** via
`respond_to { |format| format.turbo_stream }`, not inline `render turbo_stream:`
in the controller β€” the template keeps markup in views and out of actions.
- **Don't set `turbo: false` to make something work β€” find the root cause.**
Links that leave a frame use `data: { turbo_frame: "_top" }` (or a `target: "_top"`
frame); keep `turbo: false` for downloads, new tabs, and pages that truly need a
full load. Turbo already disables a form's submitter while it submits, so a Turbo
form needs no double-submit guard (`submit_once` is for forms that stay non-Turbo).

## Features & tips page (`/features`)

The login-gated **Features & tips** page lists shipped, user-facing features
Expand Down
40 changes: 14 additions & 26 deletions app/controllers/admin/home_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ module Admin
class HomeController < ApplicationController
include AdminCardsHelper

DATA_HEALTH_BADGE_TITLE = "Data checks only. Possible duplicates and author credit are counted separately on the Data health page.".freeze
REVIEW_BADGE = "Review".freeze
DATA_HEALTH_BADGE_TITLE = "Data checks only. Possible duplicates and author credit are listed separately on the Data health page.".freeze

def index
authorize! :home, to: :index?
authorize! :home

@system_cards = system_cards
@user_content_cards = user_content_cards
Expand All @@ -14,38 +15,25 @@ def index
@deduper_cards = deduper_cards
@system_settings_cards = system_settings_cards
@deprecated_data_cards = deprecated_data_cards
@pending_change_requests_count = ProfileChangeRequest.pending.count
end

def data_health_count
authorize! :home, to: :index?

render_count_badge(:data_health_count, title: DATA_HEALTH_BADGE_TITLE) { DataHealth.checks.sum(&:count) }
end

def author_credit_count
def action_buttons
authorize! :home, to: :index?

render_count_badge(:author_credit_count) { DataHealth::Worklists.new.author_credit.sum(&:count) }
end

def unsynced_features_count
authorize! Feature, to: :create?

render_count_badge(:unsynced_features_count) { FeatureCatalog.new.missing_count }
@pending_change_requests_count = safely { ProfileChangeRequest.pending.count }
@data_health_flagged = safely { DataHealth.checks.any?(&:flagged?) }
@author_credit_flagged = safely { AuthorCreditDivergenceQuery.new.any? }
@features_unsynced = safely { FeatureCatalog.new.recent_missing? } if allowed_to?(:create?, Feature)
end

private

# A badge is a nicety, so a failing count renders an empty frame and leaves the button usable.
def render_count_badge(frame_id, title: nil)
count = begin
yield
rescue StandardError => e
Rails.error.report(e)
nil
end
render partial: "count_badge_frame", locals: { frame_id: frame_id, count: count, title: title }
# A badge is a nicety, so a failing check leaves its badge empty and the buttons usable.
def safely
yield
rescue StandardError => e
Rails.error.report(e)
nil
end
end
end
48 changes: 48 additions & 0 deletions app/services/author_credit_divergence_query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ def call
)
end

# Whether any section has a row, in SQL so even an empty worklist never loads a record.
def any?
authorable_models.any? { |model| unauthored_divergences(model).exists? || preference_divergences(model).exists? }
end

private

attr_reader :person_id, :type, :preference, :include_reconciled
Expand Down Expand Up @@ -101,6 +106,49 @@ def preference_groups
group_by_person(records)
end

# SQL twin of AuthorCreditable#author_credit_diverged? plus build_group's person rules.
def preference_divergences(model)
table = model.table_name
scope = model.where.not(author_credit_preference: nil)
.joins("INNER JOIN #{Person.table_name} authors ON authors.id = #{table}.author_id")
.where("TRIM(#{table}.author_credit_preference) <> ''")
.where("#{table}.author_credit_preference <> CASE WHEN authors.anonymous_contributions THEN 'anonymous' " \
"ELSE COALESCE(NULLIF(TRIM(authors.display_name_preference), ''), 'full_name') END")
scope = scope.where(author_credit_preference: preference) if preference
counted_people(scope, :authors)
end

# Every author-less record lands in exactly one of legacy, creator, or unattributed;
# only the creator section survives a preference or person filter.
def unauthored_divergences(model)
scope = model.where(author_id: nil).joins(
"LEFT JOIN #{User.table_name} creators ON creators.id = #{model.table_name}.created_by_id " \
"LEFT JOIN #{Person.table_name} creator_people ON creator_people.id = creators.person_id"
)
legacy = legacy_name_present(model)
creator_fallback = counted_people(scope, :creator_people)
if preference || person_id
return legacy ? creator_fallback.where.not(legacy) : creator_fallback
end

unattributed_or_creator = scope.where(creator_people: { id: nil }).or(creator_fallback)
legacy ? unattributed_or_creator.or(scope.where(legacy)) : unattributed_or_creator
end

def legacy_name_present(model)
model.legacy_author_name_columns.map do |qualified_column|
table, column = qualified_column.split(".")
Arel::Nodes::NamedFunction.new("COALESCE", [ Arel::Table.new(table)[column], Arel::Nodes.build_quoted("") ])
.matches_regexp("[^[:space:]]")
end.reduce(:or)
end

def counted_people(scope, person_alias)
scope = scope.where.not(person_alias => { id: nil })
scope = scope.where(person_alias => { id: person_id.to_i }) if person_id.present?
include_reconciled ? scope : scope.where(person_alias => { author_credit_reconciled_at: nil })
end

# No credited person here, which is the whole problem β€” so no person filter.
def legacy_groups
groups = authorable_models.flat_map do |model|
Expand Down
3 changes: 3 additions & 0 deletions app/services/data_health/check.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ def count

def any? = count.positive?

# Stops at the first wrong row, for callers that only need to know whether there is one.
def flagged? = scope.exists?

def page(number)
ordered_scope.paginate(page: number, per_page: PER_PAGE, total_entries: count)
end
Expand Down
2 changes: 2 additions & 0 deletions app/services/data_health/unconfigured_app_settings.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ def count
@count ||= missing.any? ? 1 : 0
end

def flagged? = any?

def lists_rows? = false

def scope = Setting.none
Expand Down
16 changes: 10 additions & 6 deletions app/services/feature_catalog.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
# left entirely alone.
class FeatureCatalog
DATA_DIR = Rails.root.join("db/seeds/features")
RECENT_SEEDS = 20

SYNCED_FIELDS = %i[
area display_status released_on action_path pr_number
Expand Down Expand Up @@ -49,20 +50,23 @@ def import!
Result.new(created, updated, skipped)
end

def missing_count
names = entries.map { |entry| entry.fetch("name") }
# Reads only the newest seeds, so a backdated seed merged after a sync waits for a newer one to flag it.
def recent_missing?(limit: RECENT_SEEDS)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: Only the newest 20 seed files are checked, so a seed with an older filename date merged after a sync won't light the badge until a newer seed does. Sync itself still imports everything.

names = seed_paths.last(limit).map { |path| load_seed(path).fetch("name") }
existing = Feature.where(name: names).pluck(:name).to_set(&:downcase)
names.count { |name| existing.exclude?(name.downcase) }
names.any? { |name| existing.exclude?(name.downcase) }
end

def entries
Dir[File.join(@dir, "*.yml")].sort.map do |path|
YAML.safe_load_file(path, permitted_classes: [ Date ])
end
seed_paths.map { |path| load_seed(path) }
end

private

def seed_paths = Dir[File.join(@dir, "*.yml")].sort

def load_seed(path) = YAML.safe_load_file(path, permitted_classes: [ Date ])

# Keyed by downcased name to match the case-insensitive column collation.
def existing_features(names)
Feature.with_rich_text_rhino_description.where(name: names).index_by { |feature| feature.name.downcase }
Expand Down
27 changes: 27 additions & 0 deletions app/views/admin/home/_action_buttons.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<div class="mt-3 flex flex-wrap gap-2">
<%= link_to profile_change_requests_path, class: button_classes(:primary_outline) do %>
<i class="fa-solid fa-user-pen"></i> Changes requested
<%= render "count_badge", count: local_assigns[:pending_change_requests_count] %>
<% end %>
<%= link_to admin_data_health_path, class: button_classes(:primary_outline) do %>
<i class="fa-solid fa-heart-pulse"></i> Data health
<%= render "badge", text: (Admin::HomeController::REVIEW_BADGE if local_assigns[:data_health_flagged]), title: Admin::HomeController::DATA_HEALTH_BADGE_TITLE %>
<% end %>
<% if allowed_to?(:import?, EventRegistration) %>
<%= link_to new_event_registration_import_path(return_to: "admin_home"), class: button_classes(:primary_outline) do %>
<i class="fa-solid fa-upload"></i> Import registrants
<% end %>
<% end %>
<%= link_to author_credit_divergences_path, class: button_classes(:primary_outline) do %>
<i class="fa-solid fa-pen-nib"></i> Author credit divergences
<%= render "badge", text: (Admin::HomeController::REVIEW_BADGE if local_assigns[:author_credit_flagged]) %>
<% end %>
<% if allowed_to?(:create?, Feature) %>
<%= button_to import_features_path,
class: button_classes(:primary_outline),
form: { data: { turbo_confirm: "Add any newly-shipped features and fill in missing details? Your existing edits are kept." } } do %>
<i class="fa-solid fa-arrows-rotate"></i> Sync features &amp; tips
<%= render "badge", text: (Admin::HomeController::REVIEW_BADGE if local_assigns[:features_unsynced]) %>
<% end %>
<% end %>
</div>
3 changes: 3 additions & 0 deletions app/views/admin/home/_badge.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<% if text.present? %>
<%= tag.span text, class: "bg-primary ms-1 rounded-full px-2 py-0.5 text-xs text-white ring-1 ring-white", title: local_assigns[:title] %>
<% end %>
4 changes: 1 addition & 3 deletions app/views/admin/home/_count_badge.html.erb
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
<% if count.to_i.positive? %>
<%= tag.span number_with_delimiter(count), class: "bg-primary ms-1 rounded-full px-2 py-0.5 text-xs text-white ring-1 ring-white", title: local_assigns[:title] %>
<% end %>
<%= render "badge", text: (number_with_delimiter(count) if count.to_i.positive?), title: local_assigns[:title] %>
3 changes: 0 additions & 3 deletions app/views/admin/home/_count_badge_frame.html.erb

This file was deleted.

7 changes: 7 additions & 0 deletions app/views/admin/home/action_buttons.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<%= turbo_frame_tag :action_buttons, target: "_top" do %>
<%= render "action_buttons",
pending_change_requests_count: @pending_change_requests_count,
data_health_flagged: @data_health_flagged,
author_credit_flagged: @author_credit_flagged,
features_unsynced: @features_unsynced %>
<% end %>
30 changes: 3 additions & 27 deletions app/views/admin/home/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -152,33 +152,9 @@

<div class="border-primary/10 rounded-2xl border bg-white p-6 shadow-sm md:flex-1">
Actions
<div class="mt-3 flex flex-wrap gap-2">
<%= link_to profile_change_requests_path, class: button_classes(:primary_outline) do %>
<i class="fa-solid fa-user-pen"></i> Changes requested
<%= render "count_badge", count: @pending_change_requests_count %>
<% end %>
<%= link_to admin_data_health_path, class: button_classes(:primary_outline) do %>
<i class="fa-solid fa-heart-pulse"></i> Data health
<%= turbo_frame_tag :data_health_count, src: admin_home_data_health_count_path, loading: :lazy %>
<% end %>
<% if allowed_to?(:import?, EventRegistration) %>
<%= link_to new_event_registration_import_path(return_to: "admin_home"), class: button_classes(:primary_outline) do %>
<i class="fa-solid fa-upload"></i> Import registrants
<% end %>
<% end %>
<%= link_to author_credit_divergences_path, class: button_classes(:primary_outline) do %>
<i class="fa-solid fa-pen-nib"></i> Author credit divergences
<%= turbo_frame_tag :author_credit_count, src: admin_home_author_credit_count_path, loading: :lazy %>
<% end %>
<% if allowed_to?(:create?, Feature) %>
<%= button_to import_features_path,
class: button_classes(:primary_outline),
form: { data: { turbo_confirm: "Add any newly-shipped features and fill in missing details? Your existing edits are kept." } } do %>
<i class="fa-solid fa-arrows-rotate"></i> Sync features &amp; tips
<%= turbo_frame_tag :unsynced_features_count, src: admin_home_unsynced_features_count_path, loading: :lazy %>
<% end %>
<% end %>
</div>
<%= turbo_frame_tag :action_buttons, src: admin_home_action_buttons_path, target: "_top" do %>
<%= render "action_buttons" %>
<% end %>
</div>
</div>

Expand Down
4 changes: 1 addition & 3 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,7 @@

namespace :admin do
get "/", to: "home#index" # admin home page
get "home/data_health_count", to: "home#data_health_count", as: "home_data_health_count"
get "home/author_credit_count", to: "home#author_credit_count", as: "home_author_credit_count"
get "home/unsynced_features_count", to: "home#unsynced_features_count", as: "home_unsynced_features_count"
get "home/action_buttons", to: "home#action_buttons", as: "home_action_buttons"
get "activities/events", to: "ahoy_activities#index", as: "activities_events"
get "activities/events/:id", to: "ahoy_activities#show", as: "activities_event"
get "activities/visits", to: "ahoy_activities#visits", as: "activities_visits"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
name: "Admin home links work while the action badges load"
area: reporting
display_status: admin_facing
released_on: 2026-10-25
pr_number: 2847
summary: >-
Admin home's action badges now load together in the background, so clicks
no longer wait for them. Data health, Author credit, and Sync features & tips
show "Review" when something needs attention.
Loading
Loading