discourse/app/services/admin_dashboard_highlights.rb
Natalie Tay 728e70f507
FEATURE: Admin dashboard highlights to real data (#39895)
Behind the `dashboard_improvements` setting, the Highlights row is
hardcoded

This commit updates `/admin/dashboard.json` to swap out to a
`sections.highlights`. The endpoint takes optional `start_date` /
`end_date` so the redesigned page passes its picker range. Legacy
dashboard / `AdminDashboard.fetch()` callers get the same response
without the new stuff.

Core ships three KPIs (`new_signups`, `dau_mau`, `new_contributors`).
Plugins register more via a new
`register_admin_dashboard_highlight_kpi(type:, report:, enabled:)`
(discourse-solved for now). Each plugin owns their own KPI stuff.

Caching uses the existing per-report layer (`Report.find_cached`) rather
than wrapping the service in its own cache. Toggling a plugin's
`enabled:` takes effect immediately, the cache is shared with the
Reports tab when an admin drills into a tile, and the cache key isn't
fragmented per admin since the data is admin-agnostic.

The server returns `report_type` + `report_query` instead of a full URL
so routes are stable.

Without solved:

https://github.com/user-attachments/assets/28612829-b425-4664-bfb9-02e8783f5b93

With solved:

https://github.com/user-attachments/assets/3c6b6d6f-44b7-4c61-9b23-0aaaa3f21ddc
2026-05-12 13:15:52 +08:00

108 lines
2.8 KiB
Ruby
Vendored

# frozen_string_literal: true
class AdminDashboardHighlights
DEFAULT_RANGE_DAYS = 30
KPI_REPORTS = {
new_signups: "signups",
dau_mau: "dau_by_mau",
new_contributors: "new_contributors",
}.freeze
def self.build(start_date:, end_date:)
new(start_date: start_date, end_date: end_date).build
end
def initialize(start_date:, end_date:)
@start_date = parse_date(start_date) || DEFAULT_RANGE_DAYS.days.ago.beginning_of_day
@end_date = parse_date(end_date)&.end_of_day || Time.zone.now.end_of_day
end
def build
{ kpis: build_kpis }
end
private
attr_reader :start_date, :end_date
def parse_date(value)
return nil if value.blank?
Time.zone.parse(value.to_s)&.beginning_of_day
rescue ArgumentError, TypeError
nil
end
def build_kpis
core_kpis = KPI_REPORTS.map { |type, report| { type: type, report: report } }
all_kpis = core_kpis + DiscoursePluginRegistry.admin_dashboard_highlight_kpis
all_kpis.filter_map do |kpi|
next if kpi[:enabled].respond_to?(:call) && !kpi[:enabled].call
build_kpi(kpi[:type], kpi[:report])
end
end
def build_kpi(type, report_name)
args = { start_date: start_date, end_date: end_date, facets: %i[prev_period] }
report = Report.find_cached(report_name, args)
if report.nil?
report = Report.find(report_name, args)
Report.cache(report) if report && report.error.blank?
end
return nil if report.nil? || report_error?(report) || report_data(report).nil?
current = period_value(type, report_data(report))
previous = report_prev_period(report)
{
type: type,
value: current,
previous_value: previous,
percent_change: compute_percent_change(current, previous),
report_type: report_name,
report_query: {
start_date: start_date.to_date.iso8601,
end_date: end_date.to_date.iso8601,
},
}
end
# Report.find returns a Report object
# Report.find_cached returns the as_json hash (symbol-keyed).
# The accessors below cope with either
def report_error?(report_or_hash)
report_or_hash.is_a?(Hash) ? report_or_hash[:error].present? : report_or_hash.error.present?
end
def report_data(report_or_hash)
report_or_hash.is_a?(Hash) ? report_or_hash[:data] : report_or_hash.data
end
def report_prev_period(report_or_hash)
if report_or_hash.is_a?(Hash)
report_or_hash[:prev_period]
else
report_or_hash.prev_period
end
end
def period_value(type, data)
return nil if data.empty?
ys = data.map { |point| point[:y] }
if type == :dau_mau
(ys.sum(&:to_f) / ys.size).round(1)
else
ys.sum(&:to_i)
end
end
def compute_percent_change(current, previous)
return nil if previous.blank? || previous.zero? || current.blank?
((current.to_f - previous) / previous * 100).round(2)
end
end