From f8d9a13102db7f0b7669ed5c478584d312b4b6ee Mon Sep 17 00:00:00 2001 From: Anna Topalidi Date: Tue, 24 Feb 2026 14:15:13 +0100 Subject: [PATCH 01/15] add submenu --- .rubocop.yml | 4 ++ .../admin/insights_controller.rb | 38 +++++++++++++++++ .../extra_user_fields/admin/permissions.rb | 8 ++++ .../admin/insights/show.html.erb | 11 +++++ config/i18n-tasks.yml | 1 + config/locales/en.yml | 5 +++ lib/decidim/extra_user_fields.rb | 1 + lib/decidim/extra_user_fields/admin_engine.rb | 38 +++++++++++++++++ .../extra_user_fields/insights_engine.rb | 15 +++++++ spec/permissions/admin/permissions_spec.rb | 16 +++++++ spec/system/admin_views_insights_spec.rb | 42 +++++++++++++++++++ 11 files changed, 179 insertions(+) create mode 100644 app/controllers/decidim/extra_user_fields/admin/insights_controller.rb create mode 100644 app/views/decidim/extra_user_fields/admin/insights/show.html.erb create mode 100644 lib/decidim/extra_user_fields/insights_engine.rb create mode 100644 spec/system/admin_views_insights_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index e85f6bae..0eba4a84 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -15,3 +15,7 @@ AllCops: - "node_modules/**/*" - "db/schema.rb" - "vendor/**/*" + +RSpec/DescribeClass: + Exclude: + - "spec/system/**/*" diff --git a/app/controllers/decidim/extra_user_fields/admin/insights_controller.rb b/app/controllers/decidim/extra_user_fields/admin/insights_controller.rb new file mode 100644 index 00000000..50f6232f --- /dev/null +++ b/app/controllers/decidim/extra_user_fields/admin/insights_controller.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Decidim + module ExtraUserFields + module Admin + class InsightsController < Decidim::Admin::ApplicationController + include Decidim::Admin::ParticipatorySpaceAdminContext + layout :layout + + before_action :set_breadcrumbs + + def show + enforce_permission_to :read, :insights + end + + private + + def permission_class_chain + [::Decidim::ExtraUserFields::Admin::Permissions] + super + end + + def current_participatory_space + @current_participatory_space ||= + Decidim::ParticipatoryProcess.find_by(organization: current_organization, slug: params[:participatory_process_slug]) || + Decidim::Assembly.find_by!(organization: current_organization, slug: params[:assembly_slug]) + end + + def set_breadcrumbs + if params[:participatory_process_slug] + secondary_breadcrumb_menus << :admin_participatory_process_menu + elsif params[:assembly_slug] + secondary_breadcrumb_menus << :admin_assembly_menu + end + end + end + end + end +end diff --git a/app/permissions/decidim/extra_user_fields/admin/permissions.rb b/app/permissions/decidim/extra_user_fields/admin/permissions.rb index cc2024f8..6d905dfe 100644 --- a/app/permissions/decidim/extra_user_fields/admin/permissions.rb +++ b/app/permissions/decidim/extra_user_fields/admin/permissions.rb @@ -10,10 +10,18 @@ def permissions allow! if access_extra_user_fields? allow! if update_extra_user_fields? + allow! if read_insights? permission_action end + private + + def read_insights? + permission_action.subject == :insights && + permission_action.action == :read + end + def access_extra_user_fields? permission_action.subject == :extra_user_fields && permission_action.action == :read diff --git a/app/views/decidim/extra_user_fields/admin/insights/show.html.erb b/app/views/decidim/extra_user_fields/admin/insights/show.html.erb new file mode 100644 index 00000000..105c2cb5 --- /dev/null +++ b/app/views/decidim/extra_user_fields/admin/insights/show.html.erb @@ -0,0 +1,11 @@ +<% add_decidim_page_title(t("decidim.admin.extra_user_fields.insights.title")) %> +
+

+ <%= t("decidim.admin.extra_user_fields.insights.title") %> +

+
+
+
+

<%= t("decidim.admin.extra_user_fields.insights.description") %>

+
+
diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index d31a358b..18afd0b1 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -8,6 +8,7 @@ ignore_unused: - activemodel.attributes.user.* - activemodel.errors.models.user.* - decidim.admin.extra_user_fields.menu.title + - decidim.admin.extra_user_fields.insights.* - decidim.extra_user_fields.genders.* - decidim.extra_user_fields.age_ranges.* diff --git a/config/locales/en.yml b/config/locales/en.yml index 4390e29c..a71132d0 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -25,6 +25,11 @@ en: exports: export_as: Export %{export_format} extra_user_fields: + insights: + description: Explore participant activity across profile dimensions. Select + a metric and two profile fields to see how participation is distributed. + menu_title: Insights + title: Participatory Space Insights menu: title: Manage extra user fields components: diff --git a/lib/decidim/extra_user_fields.rb b/lib/decidim/extra_user_fields.rb index 022dc464..c77be0ff 100644 --- a/lib/decidim/extra_user_fields.rb +++ b/lib/decidim/extra_user_fields.rb @@ -3,6 +3,7 @@ require "decidim/extra_user_fields/admin" require "decidim/extra_user_fields/engine" require "decidim/extra_user_fields/admin_engine" +require "decidim/extra_user_fields/insights_engine" require "decidim/extra_user_fields/form_builder_methods" module Decidim diff --git a/lib/decidim/extra_user_fields/admin_engine.rb b/lib/decidim/extra_user_fields/admin_engine.rb index 1ac0084c..02581553 100644 --- a/lib/decidim/extra_user_fields/admin_engine.rb +++ b/lib/decidim/extra_user_fields/admin_engine.rb @@ -46,6 +46,44 @@ class AdminEngine < ::Rails::Engine end end + initializer "decidim_extra_user_fields.insights_routes" do + Decidim::Core::Engine.routes do + scope "/admin/participatory_processes/:participatory_process_slug" do + mount Decidim::ExtraUserFields::InsightsEngine, + at: "/insights", + as: "decidim_admin_participatory_process_insights" + end + + scope "/admin/assemblies/:assembly_slug" do + mount Decidim::ExtraUserFields::InsightsEngine, + at: "/insights", + as: "decidim_admin_assembly_insights" + end + end + end + + initializer "decidim_extra_user_fields.insights_menu" do + Decidim.menu :admin_participatory_process_menu do |menu| + menu.add_item :insights, + I18n.t("decidim.admin.extra_user_fields.insights.menu_title"), + decidim_admin_participatory_process_insights.root_path( + participatory_process_slug: current_participatory_space.slug + ), + icon_name: "bar-chart-2-line", + position: 9 + end + + Decidim.menu :admin_assembly_menu do |menu| + menu.add_item :insights, + I18n.t("decidim.admin.extra_user_fields.insights.menu_title"), + decidim_admin_assembly_insights.root_path( + assembly_slug: current_participatory_space.slug + ), + icon_name: "bar-chart-2-line", + position: 9 + end + end + def load_seed nil end diff --git a/lib/decidim/extra_user_fields/insights_engine.rb b/lib/decidim/extra_user_fields/insights_engine.rb new file mode 100644 index 00000000..4061a961 --- /dev/null +++ b/lib/decidim/extra_user_fields/insights_engine.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Decidim + module ExtraUserFields + # Engine for the Insights feature, mounted under participatory space admin URLs. + # Provides pivot-table statistics scoped to a specific participatory space. + class InsightsEngine < ::Rails::Engine + isolate_namespace Decidim::ExtraUserFields + + routes do + root to: "admin/insights#show" + end + end + end +end diff --git a/spec/permissions/admin/permissions_spec.rb b/spec/permissions/admin/permissions_spec.rb index 6e29702e..8edac298 100644 --- a/spec/permissions/admin/permissions_spec.rb +++ b/spec/permissions/admin/permissions_spec.rb @@ -29,6 +29,14 @@ module Decidim::ExtraUserFields::Admin it_behaves_like "permission is not set" end + + context "when reading insights" do + let(:action) do + { scope: :admin, action: :read, subject: :insights } + end + + it { is_expected.to be_truthy } + end end context "when user is not admin" do @@ -49,6 +57,14 @@ module Decidim::ExtraUserFields::Admin it_behaves_like "permission is not set" end + + context "and tries to read insights" do + let(:action) do + { scope: :admin, action: :read, subject: :insights } + end + + it_behaves_like "permission is not set" + end end end end diff --git a/spec/system/admin_views_insights_spec.rb b/spec/system/admin_views_insights_spec.rb new file mode 100644 index 00000000..7bf5673d --- /dev/null +++ b/spec/system/admin_views_insights_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Admin views insights" do + let(:organization) { create(:organization) } + let!(:participatory_process) { create(:participatory_process, organization:) } + let(:user) { create(:user, :admin, :confirmed, organization:) } + + before do + switch_to_host(organization.host) + login_as user, scope: :user + end + + context "when visiting a participatory process admin" do + before do + visit decidim_admin_participatory_processes.edit_participatory_process_path(participatory_process) + end + + it "shows the Insights menu item" do + within ".sidebar-menu" do + expect(page).to have_content("Insights") + end + end + end + + context "when visiting the insights page" do + before do + visit decidim_admin_participatory_process_insights.root_path( + participatory_process_slug: participatory_process.slug + ) + end + + it "displays the page title" do + expect(page).to have_content("Participatory Space Insights") + end + + it "displays the description" do + expect(page).to have_content("Explore participant activity across profile dimensions") + end + end +end From 037c0e20b4da064a875115f4c0a5023f99c4035d Mon Sep 17 00:00:00 2001 From: Anna Topalidi Date: Wed, 25 Feb 2026 12:01:14 +0100 Subject: [PATCH 02/15] Add insights core logic: metrics, pivot table, and builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement Phase 2 of the Insights feature: - PivotTable value object for cross-tabulation data - 5 metric query classes (participants, proposals created/supported, comments, budget votes) - PivotTableBuilder service to orchestrate metric → user data → pivot table - InsightMetrics facade reading from config_accessor registry - Full unit test coverage (33 new specs) --- .rubocop.yml | 1 + .../decidim/extra_user_fields/pivot_table.rb | 56 +++++++++++ .../extra_user_fields/insight_metrics.rb | 24 +++++ .../extra_user_fields/metrics/base_metric.rb | 92 +++++++++++++++++++ .../metrics/budget_votes_metric.rb | 20 ++++ .../metrics/comments_metric.rb | 17 ++++ .../metrics/participants_metric.rb | 48 ++++++++++ .../metrics/proposals_created_metric.rb | 21 +++++ .../metrics/proposals_supported_metric.rb | 19 ++++ .../extra_user_fields/pivot_table_builder.rb | 85 +++++++++++++++++ lib/decidim/extra_user_fields.rb | 15 +++ .../extra_user_fields/test/factories.rb | 3 + .../extra_user_fields/pivot_table_spec.rb | 83 +++++++++++++++++ .../extra_user_fields/insight_metrics_spec.rb | 43 +++++++++ .../metrics/budget_votes_metric_spec.rb | 48 ++++++++++ .../metrics/comments_metric_spec.rb | 36 ++++++++ .../metrics/participants_metric_spec.rb | 62 +++++++++++++ .../metrics/proposals_created_metric_spec.rb | 47 ++++++++++ .../proposals_supported_metric_spec.rb | 38 ++++++++ .../pivot_table_builder_spec.rb | 86 +++++++++++++++++ .../admin_manages_officializations_spec.rb | 2 +- ...ges_organization_extra_user_fields_spec.rb | 2 +- spec/system/registration_spec.rb | 2 +- 23 files changed, 847 insertions(+), 3 deletions(-) create mode 100644 app/models/decidim/extra_user_fields/pivot_table.rb create mode 100644 app/queries/decidim/extra_user_fields/insight_metrics.rb create mode 100644 app/queries/decidim/extra_user_fields/metrics/base_metric.rb create mode 100644 app/queries/decidim/extra_user_fields/metrics/budget_votes_metric.rb create mode 100644 app/queries/decidim/extra_user_fields/metrics/comments_metric.rb create mode 100644 app/queries/decidim/extra_user_fields/metrics/participants_metric.rb create mode 100644 app/queries/decidim/extra_user_fields/metrics/proposals_created_metric.rb create mode 100644 app/queries/decidim/extra_user_fields/metrics/proposals_supported_metric.rb create mode 100644 app/services/decidim/extra_user_fields/pivot_table_builder.rb create mode 100644 spec/models/decidim/extra_user_fields/pivot_table_spec.rb create mode 100644 spec/queries/decidim/extra_user_fields/insight_metrics_spec.rb create mode 100644 spec/queries/decidim/extra_user_fields/metrics/budget_votes_metric_spec.rb create mode 100644 spec/queries/decidim/extra_user_fields/metrics/comments_metric_spec.rb create mode 100644 spec/queries/decidim/extra_user_fields/metrics/participants_metric_spec.rb create mode 100644 spec/queries/decidim/extra_user_fields/metrics/proposals_created_metric_spec.rb create mode 100644 spec/queries/decidim/extra_user_fields/metrics/proposals_supported_metric_spec.rb create mode 100644 spec/services/decidim/extra_user_fields/pivot_table_builder_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index 0eba4a84..d2b0cf97 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -19,3 +19,4 @@ AllCops: RSpec/DescribeClass: Exclude: - "spec/system/**/*" + - spec/i18n_spec.rb diff --git a/app/models/decidim/extra_user_fields/pivot_table.rb b/app/models/decidim/extra_user_fields/pivot_table.rb new file mode 100644 index 00000000..64398007 --- /dev/null +++ b/app/models/decidim/extra_user_fields/pivot_table.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Decidim + module ExtraUserFields + # Value object representing a cross-tabulation (pivot) table. + # Holds row values, column values, a 2D cell hash, and computed totals. + class PivotTable + attr_reader :row_values, :col_values, :cells + + # @param row_values [Array] sorted unique values for the row axis + # @param col_values [Array] sorted unique values for the column axis + # @param cells [Hash{String => Hash{String => Integer}}] cells[row][col] = count + def initialize(row_values:, col_values:, cells:) + @row_values = row_values + @col_values = col_values + @cells = cells + end + + def cell(row, col) + cells.dig(row, col) || 0 + end + + def row_total(row) + row_totals[row] + end + + def col_total(col) + col_totals[col] + end + + def grand_total + @grand_total ||= row_totals.values.sum + end + + def row_totals + @row_totals ||= row_values.index_with do |row| + col_values.sum { |col| cell(row, col) } + end + end + + def col_totals + @col_totals ||= col_values.index_with do |col| + row_values.sum { |row| cell(row, col) } + end + end + + def max_value + @max_value ||= cells.values.flat_map(&:values).max || 0 + end + + def empty? + grand_total.zero? + end + end + end +end diff --git a/app/queries/decidim/extra_user_fields/insight_metrics.rb b/app/queries/decidim/extra_user_fields/insight_metrics.rb new file mode 100644 index 00000000..3d54c0a6 --- /dev/null +++ b/app/queries/decidim/extra_user_fields/insight_metrics.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Decidim + module ExtraUserFields + # Facade for the insight metrics registry. + # Reads from Decidim::ExtraUserFields.insight_metrics config. + module InsightMetrics + def self.available_metrics + Decidim::ExtraUserFields.insight_metrics.keys + end + + def self.metric_class(name) + class_name = Decidim::ExtraUserFields.insight_metrics[name.to_s] + return unless class_name + + class_name.constantize + end + + def self.valid_metric?(name) + Decidim::ExtraUserFields.insight_metrics.has_key?(name.to_s) + end + end + end +end diff --git a/app/queries/decidim/extra_user_fields/metrics/base_metric.rb b/app/queries/decidim/extra_user_fields/metrics/base_metric.rb new file mode 100644 index 00000000..65a08946 --- /dev/null +++ b/app/queries/decidim/extra_user_fields/metrics/base_metric.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module Decidim + module ExtraUserFields + module Metrics + # Base class for all insight metrics. + # Each subclass returns a hash of { user_id => count } scoped to a participatory space. + class BaseMetric + def initialize(participatory_space) + @participatory_space = participatory_space + end + + # @return [Hash{Integer => Integer}] user_id => count + def call + raise NotImplementedError, "#{self.class}#call must be implemented" + end + + private + + attr_reader :participatory_space + + def component_ids_for(manifest_name) + participatory_space.components.where(manifest_name: manifest_name).published.pluck(:id) + end + + def proposal_ids + @proposal_ids ||= fetch_proposal_ids + end + + def budget_ids + @budget_ids ||= fetch_budget_ids + end + + # Find comments on resources within this space. + # Returns an ActiveRecord scope (not plucked). + def comments_in_space + scopes = [] + + if proposal_ids.any? + scopes << Decidim::Comments::Comment.where( + decidim_root_commentable_type: "Decidim::Proposals::Proposal", + decidim_root_commentable_id: proposal_ids + ) + end + + if budget_project_ids.any? + scopes << Decidim::Comments::Comment.where( + decidim_root_commentable_type: "Decidim::Budgets::Project", + decidim_root_commentable_id: budget_project_ids + ) + end + + return Decidim::Comments::Comment.none if scopes.empty? + + scopes.reduce(:or) + end + + def budget_project_ids + @budget_project_ids ||= fetch_budget_project_ids + end + + def fetch_proposal_ids + ids = component_ids_for("proposals") + return [] if ids.empty? + + Decidim::Proposals::Proposal + .where(decidim_component_id: ids) + .published + .not_hidden + .pluck(:id) + end + + def fetch_budget_ids + ids = component_ids_for("budgets") + return [] if ids.empty? + + Decidim::Budgets::Budget + .where(decidim_component_id: ids) + .pluck(:id) + end + + def fetch_budget_project_ids + return [] if budget_ids.empty? + + Decidim::Budgets::Project + .where(decidim_budgets_budget_id: budget_ids) + .pluck(:id) + end + end + end + end +end diff --git a/app/queries/decidim/extra_user_fields/metrics/budget_votes_metric.rb b/app/queries/decidim/extra_user_fields/metrics/budget_votes_metric.rb new file mode 100644 index 00000000..5fbf7748 --- /dev/null +++ b/app/queries/decidim/extra_user_fields/metrics/budget_votes_metric.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Decidim + module ExtraUserFields + module Metrics + # Counts checked-out budget orders (votes) by each user within the participatory space. + class BudgetVotesMetric < BaseMetric + def call + return {} if budget_ids.empty? + + Decidim::Budgets::Order + .where(decidim_budgets_budget_id: budget_ids) + .where.not(checked_out_at: nil) + .group(:decidim_user_id) + .count + end + end + end + end +end diff --git a/app/queries/decidim/extra_user_fields/metrics/comments_metric.rb b/app/queries/decidim/extra_user_fields/metrics/comments_metric.rb new file mode 100644 index 00000000..84a28ec1 --- /dev/null +++ b/app/queries/decidim/extra_user_fields/metrics/comments_metric.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Decidim + module ExtraUserFields + module Metrics + # Counts comments made by each user on resources within the participatory space. + class CommentsMetric < BaseMetric + def call + comments_in_space + .where(decidim_author_type: "Decidim::UserBaseEntity") + .group(:decidim_author_id) + .count + end + end + end + end +end diff --git a/app/queries/decidim/extra_user_fields/metrics/participants_metric.rb b/app/queries/decidim/extra_user_fields/metrics/participants_metric.rb new file mode 100644 index 00000000..f6aa0a9d --- /dev/null +++ b/app/queries/decidim/extra_user_fields/metrics/participants_metric.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Decidim + module ExtraUserFields + module Metrics + # Counts unique participants — users who authored any resource (proposal, comment, budget vote) + # within the given participatory space. Each user is counted once. + class ParticipantsMetric < BaseMetric + def call + user_ids = Set.new + + user_ids.merge(proposal_author_ids) + user_ids.merge(comment_author_ids) + user_ids.merge(budget_voter_ids) + + user_ids.index_with { 1 } + end + + private + + def proposal_author_ids + return [] if proposal_ids.empty? + + Decidim::Coauthorship + .where(coauthorable_type: "Decidim::Proposals::Proposal", coauthorable_id: proposal_ids) + .where(decidim_author_type: "Decidim::UserBaseEntity") + .where.not(decidim_author_id: nil) + .distinct.pluck(:decidim_author_id) + end + + def comment_author_ids + comments_in_space + .where(decidim_author_type: "Decidim::UserBaseEntity") + .distinct.pluck(:decidim_author_id) + end + + def budget_voter_ids + return [] if budget_ids.empty? + + Decidim::Budgets::Order + .where(decidim_budgets_budget_id: budget_ids) + .where.not(checked_out_at: nil) + .distinct.pluck(:decidim_user_id) + end + end + end + end +end diff --git a/app/queries/decidim/extra_user_fields/metrics/proposals_created_metric.rb b/app/queries/decidim/extra_user_fields/metrics/proposals_created_metric.rb new file mode 100644 index 00000000..974d70fe --- /dev/null +++ b/app/queries/decidim/extra_user_fields/metrics/proposals_created_metric.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Decidim + module ExtraUserFields + module Metrics + # Counts proposals created by each user within the participatory space. + class ProposalsCreatedMetric < BaseMetric + def call + return {} if proposal_ids.empty? + + Decidim::Coauthorship + .where(coauthorable_type: "Decidim::Proposals::Proposal", coauthorable_id: proposal_ids) + .where(decidim_author_type: "Decidim::UserBaseEntity") + .where.not(decidim_author_id: nil) + .group(:decidim_author_id) + .count + end + end + end + end +end diff --git a/app/queries/decidim/extra_user_fields/metrics/proposals_supported_metric.rb b/app/queries/decidim/extra_user_fields/metrics/proposals_supported_metric.rb new file mode 100644 index 00000000..d5b7b797 --- /dev/null +++ b/app/queries/decidim/extra_user_fields/metrics/proposals_supported_metric.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Decidim + module ExtraUserFields + module Metrics + # Counts proposal votes (supports) made by each user within the participatory space. + class ProposalsSupportedMetric < BaseMetric + def call + return {} if proposal_ids.empty? + + Decidim::Proposals::ProposalVote + .where(decidim_proposal_id: proposal_ids) + .group(:decidim_author_id) + .count + end + end + end + end +end diff --git a/app/services/decidim/extra_user_fields/pivot_table_builder.rb b/app/services/decidim/extra_user_fields/pivot_table_builder.rb new file mode 100644 index 00000000..17956581 --- /dev/null +++ b/app/services/decidim/extra_user_fields/pivot_table_builder.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module Decidim + module ExtraUserFields + # Builds a PivotTable by: + # 1. Running a metric query to get { user_id => count } + # 2. Loading those users and reading their extended_data for row/col fields + # 3. Aggregating counts into a cross-tabulation matrix + class PivotTableBuilder + NON_SPECIFIED = "non_specified" + + # @param participatory_space [Decidim::ParticipatoryProcess, Decidim::Assembly] + # @param metric_name [String] key from InsightMetrics::REGISTRY + # @param row_field [String] extra user field name for the Y axis + # @param col_field [String] extra user field name for the X axis + def initialize(participatory_space:, metric_name:, row_field:, col_field:) + @participatory_space = participatory_space + @metric_name = metric_name + @row_field = row_field + @col_field = col_field + end + + # @return [Decidim::ExtraUserFields::PivotTable] + def call + metric_data = run_metric + return empty_pivot_table if metric_data.empty? + + users = load_users(metric_data.keys) + cells = build_cells(metric_data, users) + + row_vals = cells.keys.sort_by { |v| sort_key(v) } + col_vals = cells.values.flat_map(&:keys).uniq.sort_by { |v| sort_key(v) } + + PivotTable.new(row_values: row_vals, col_values: col_vals, cells: cells) + end + + private + + attr_reader :participatory_space, :metric_name, :row_field, :col_field + + def run_metric + klass = InsightMetrics.metric_class(metric_name) + return {} unless klass + + klass.new(participatory_space).call + end + + def load_users(user_ids) + Decidim::User + .where(id: user_ids) + .pluck(:id, :extended_data) + .to_h + end + + def build_cells(metric_data, users) + cells = Hash.new { |h, k| h[k] = Hash.new(0) } + + metric_data.each do |user_id, count| + extended_data = (users[user_id] || {}).with_indifferent_access + row_val = extract_field(extended_data, row_field) + col_val = extract_field(extended_data, col_field) + + cells[row_val][col_val] += count + end + + cells + end + + def extract_field(extended_data, field) + value = extended_data[field] + value = value.presence + value || NON_SPECIFIED + end + + def empty_pivot_table + PivotTable.new(row_values: [], col_values: [], cells: {}) + end + + # Sort values alphabetically but push "non_specified" to the end. + def sort_key(value) + value == NON_SPECIFIED ? [1, ""] : [0, value.to_s] + end + end + end +end diff --git a/lib/decidim/extra_user_fields.rb b/lib/decidim/extra_user_fields.rb index c77be0ff..012227ea 100644 --- a/lib/decidim/extra_user_fields.rb +++ b/lib/decidim/extra_user_fields.rb @@ -77,5 +77,20 @@ module ExtraUserFields motto: false } end + + # Registry of insight metrics available for pivot tables. + # Keys are metric identifiers, values are fully-qualified class names. + # Custom metrics can be added via an initializer: + # Decidim::ExtraUserFields.config.insight_metrics["my_metric"] = "MyApp::Metrics::CustomMetric" + # Each class must implement `initialize(participatory_space)` and `call` returning { user_id => count }. + config_accessor :insight_metrics do + { + "participants" => "Decidim::ExtraUserFields::Metrics::ParticipantsMetric", + "proposals_created" => "Decidim::ExtraUserFields::Metrics::ProposalsCreatedMetric", + "proposals_supported" => "Decidim::ExtraUserFields::Metrics::ProposalsSupportedMetric", + "comments" => "Decidim::ExtraUserFields::Metrics::CommentsMetric", + "budget_votes" => "Decidim::ExtraUserFields::Metrics::BudgetVotesMetric" + } + end end end diff --git a/lib/decidim/extra_user_fields/test/factories.rb b/lib/decidim/extra_user_fields/test/factories.rb index 5fae2744..63db0f89 100644 --- a/lib/decidim/extra_user_fields/test/factories.rb +++ b/lib/decidim/extra_user_fields/test/factories.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true require "decidim/core/test/factories" +require "decidim/proposals/test/factories" +require "decidim/budgets/test/factories" +require "decidim/comments/test/factories" FactoryBot.define do factory :extra_user_fields_component, parent: :component do diff --git a/spec/models/decidim/extra_user_fields/pivot_table_spec.rb b/spec/models/decidim/extra_user_fields/pivot_table_spec.rb new file mode 100644 index 00000000..f3baa7ca --- /dev/null +++ b/spec/models/decidim/extra_user_fields/pivot_table_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::ExtraUserFields + describe PivotTable do + subject(:pivot_table) { described_class.new(row_values: row_values, col_values: col_values, cells: cells) } + + let(:row_values) { %w(18_to_25 26_to_40) } + let(:col_values) { %w(female male) } + let(:cells) do + { + "18_to_25" => { "female" => 10, "male" => 5 }, + "26_to_40" => { "female" => 20, "male" => 15 } + } + end + + describe "#cell" do + it "returns the count for a given row and column" do + expect(pivot_table.cell("18_to_25", "female")).to eq(10) + expect(pivot_table.cell("26_to_40", "male")).to eq(15) + end + + it "returns 0 for missing cells" do + expect(pivot_table.cell("unknown", "female")).to eq(0) + end + end + + describe "#row_total" do + it "sums all columns for a given row" do + expect(pivot_table.row_total("18_to_25")).to eq(15) + expect(pivot_table.row_total("26_to_40")).to eq(35) + end + end + + describe "#col_total" do + it "sums all rows for a given column" do + expect(pivot_table.col_total("female")).to eq(30) + expect(pivot_table.col_total("male")).to eq(20) + end + end + + describe "#grand_total" do + it "returns the sum of all cells" do + expect(pivot_table.grand_total).to eq(50) + end + end + + describe "#max_value" do + it "returns the highest cell value" do + expect(pivot_table.max_value).to eq(20) + end + end + + describe "#empty?" do + it "returns false when there is data" do + expect(pivot_table.empty?).to be(false) + end + + context "when all cells are zero" do + let(:cells) do + { + "18_to_25" => { "female" => 0, "male" => 0 } + } + end + + it "returns true" do + expect(pivot_table.empty?).to be(true) + end + end + + context "when there are no cells" do + let(:row_values) { [] } + let(:col_values) { [] } + let(:cells) { {} } + + it "returns true" do + expect(pivot_table.empty?).to be(true) + end + end + end + end +end diff --git a/spec/queries/decidim/extra_user_fields/insight_metrics_spec.rb b/spec/queries/decidim/extra_user_fields/insight_metrics_spec.rb new file mode 100644 index 00000000..ba5c8da9 --- /dev/null +++ b/spec/queries/decidim/extra_user_fields/insight_metrics_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::ExtraUserFields + describe InsightMetrics do + describe ".available_metrics" do + it "returns all metric names" do + expect(described_class.available_metrics).to contain_exactly( + "participants", + "proposals_created", + "proposals_supported", + "comments", + "budget_votes" + ) + end + end + + describe ".metric_class" do + it "returns the correct class for each metric" do + expect(described_class.metric_class("participants")).to eq(Metrics::ParticipantsMetric) + expect(described_class.metric_class("proposals_created")).to eq(Metrics::ProposalsCreatedMetric) + expect(described_class.metric_class("proposals_supported")).to eq(Metrics::ProposalsSupportedMetric) + expect(described_class.metric_class("comments")).to eq(Metrics::CommentsMetric) + expect(described_class.metric_class("budget_votes")).to eq(Metrics::BudgetVotesMetric) + end + + it "returns nil for unknown metrics" do + expect(described_class.metric_class("unknown")).to be_nil + end + end + + describe ".valid_metric?" do + it "returns true for valid metrics" do + expect(described_class.valid_metric?("participants")).to be(true) + end + + it "returns false for invalid metrics" do + expect(described_class.valid_metric?("unknown")).to be(false) + end + end + end +end diff --git a/spec/queries/decidim/extra_user_fields/metrics/budget_votes_metric_spec.rb b/spec/queries/decidim/extra_user_fields/metrics/budget_votes_metric_spec.rb new file mode 100644 index 00000000..4d9a8c5c --- /dev/null +++ b/spec/queries/decidim/extra_user_fields/metrics/budget_votes_metric_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::ExtraUserFields::Metrics + describe BudgetVotesMetric do + subject { described_class.new(participatory_process) } + + let(:organization) { create(:organization) } + let(:participatory_process) { create(:participatory_process, :with_steps, organization:) } + let(:budgets_component) { create(:budgets_component, :published, participatory_space: participatory_process) } + let(:budget) { create(:budget, component: budgets_component) } + let(:user1) { create(:user, :confirmed, organization:) } + let(:user2) { create(:user, :confirmed, organization:) } + + context "when there are no budget votes" do + it "returns an empty hash" do + expect(subject.call).to eq({}) + end + end + + context "when users have voted on budgets" do + before do + order1 = create(:order, :with_projects, budget:, user: user1) + order1.update!(checked_out_at: Time.current) + + order2 = create(:order, :with_projects, budget:, user: user2) + order2.update!(checked_out_at: Time.current) + end + + it "returns the vote count per user" do + result = subject.call + expect(result[user1.id]).to eq(1) + expect(result[user2.id]).to eq(1) + end + end + + context "when an order is not checked out" do + before do + create(:order, budget:, user: user1) + end + + it "does not count it" do + expect(subject.call).to eq({}) + end + end + end +end diff --git a/spec/queries/decidim/extra_user_fields/metrics/comments_metric_spec.rb b/spec/queries/decidim/extra_user_fields/metrics/comments_metric_spec.rb new file mode 100644 index 00000000..301a2ea3 --- /dev/null +++ b/spec/queries/decidim/extra_user_fields/metrics/comments_metric_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::ExtraUserFields::Metrics + describe CommentsMetric do + subject { described_class.new(participatory_process) } + + let(:organization) { create(:organization) } + let(:participatory_process) { create(:participatory_process, :with_steps, organization:) } + let(:proposal_component) { create(:proposal_component, :published, participatory_space: participatory_process) } + let(:user1) { create(:user, :confirmed, organization:) } + let(:user2) { create(:user, :confirmed, organization:) } + + context "when there are no comments" do + it "returns an empty hash" do + expect(subject.call).to eq({}) + end + end + + context "when users have commented on proposals" do + let!(:proposal) { create(:proposal, :published, component: proposal_component, users: [user1]) } + + before do + create_list(:comment, 3, commentable: proposal, author: user1) + create(:comment, commentable: proposal, author: user2) + end + + it "returns the comment count per user" do + result = subject.call + expect(result[user1.id]).to eq(3) + expect(result[user2.id]).to eq(1) + end + end + end +end diff --git a/spec/queries/decidim/extra_user_fields/metrics/participants_metric_spec.rb b/spec/queries/decidim/extra_user_fields/metrics/participants_metric_spec.rb new file mode 100644 index 00000000..da0c2ffe --- /dev/null +++ b/spec/queries/decidim/extra_user_fields/metrics/participants_metric_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::ExtraUserFields::Metrics + describe ParticipantsMetric do + subject { described_class.new(participatory_process) } + + let(:organization) { create(:organization) } + let(:participatory_process) { create(:participatory_process, :with_steps, organization:) } + let(:proposal_component) { create(:proposal_component, :published, participatory_space: participatory_process) } + let(:user1) { create(:user, :confirmed, organization:) } + let(:user2) { create(:user, :confirmed, organization:) } + + context "when there are no activities" do + it "returns an empty hash" do + expect(subject.call).to eq({}) + end + end + + context "when users have created proposals" do + before do + create(:proposal, :published, component: proposal_component, users: [user1]) + create(:proposal, :published, component: proposal_component, users: [user2]) + end + + it "returns each user counted once" do + result = subject.call + expect(result[user1.id]).to eq(1) + expect(result[user2.id]).to eq(1) + end + end + + context "when a user has multiple activities" do + let!(:proposal) { create(:proposal, :published, component: proposal_component, users: [user1]) } + + before do + create(:comment, commentable: proposal, author: user1) + end + + it "still counts the user only once" do + result = subject.call + expect(result[user1.id]).to eq(1) + end + end + + context "when users voted on budgets" do + let(:budgets_component) { create(:budgets_component, :published, participatory_space: participatory_process) } + let(:budget) { create(:budget, component: budgets_component) } + + before do + order = create(:order, :with_projects, budget:, user: user1) + order.update!(checked_out_at: Time.current) + end + + it "includes the voter" do + result = subject.call + expect(result[user1.id]).to eq(1) + end + end + end +end diff --git a/spec/queries/decidim/extra_user_fields/metrics/proposals_created_metric_spec.rb b/spec/queries/decidim/extra_user_fields/metrics/proposals_created_metric_spec.rb new file mode 100644 index 00000000..e7b56b31 --- /dev/null +++ b/spec/queries/decidim/extra_user_fields/metrics/proposals_created_metric_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::ExtraUserFields::Metrics + describe ProposalsCreatedMetric do + subject { described_class.new(participatory_process) } + + let(:organization) { create(:organization) } + let(:participatory_process) { create(:participatory_process, :with_steps, organization:) } + let(:proposal_component) { create(:proposal_component, :published, participatory_space: participatory_process) } + let(:user1) { create(:user, :confirmed, organization:) } + let(:user2) { create(:user, :confirmed, organization:) } + + context "when there are no proposals" do + it "returns an empty hash" do + expect(subject.call).to eq({}) + end + end + + context "when users have created proposals" do + before do + create_list(:proposal, 3, :published, component: proposal_component, users: [user1]) + create(:proposal, :published, component: proposal_component, users: [user2]) + end + + it "returns the count per user" do + result = subject.call + expect(result[user1.id]).to eq(3) + expect(result[user2.id]).to eq(1) + end + end + + context "when proposals belong to another space" do + let(:other_process) { create(:participatory_process, :with_steps, organization:) } + let(:other_component) { create(:proposal_component, :published, participatory_space: other_process) } + + before do + create(:proposal, :published, component: other_component, users: [user1]) + end + + it "does not count them" do + expect(subject.call).to eq({}) + end + end + end +end diff --git a/spec/queries/decidim/extra_user_fields/metrics/proposals_supported_metric_spec.rb b/spec/queries/decidim/extra_user_fields/metrics/proposals_supported_metric_spec.rb new file mode 100644 index 00000000..890b0247 --- /dev/null +++ b/spec/queries/decidim/extra_user_fields/metrics/proposals_supported_metric_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::ExtraUserFields::Metrics + describe ProposalsSupportedMetric do + subject { described_class.new(participatory_process) } + + let(:organization) { create(:organization) } + let(:participatory_process) { create(:participatory_process, :with_steps, organization:) } + let(:proposal_component) { create(:proposal_component, :published, participatory_space: participatory_process) } + let(:user1) { create(:user, :confirmed, organization:) } + let(:user2) { create(:user, :confirmed, organization:) } + + context "when there are no votes" do + it "returns an empty hash" do + expect(subject.call).to eq({}) + end + end + + context "when users have supported proposals" do + let!(:proposal1) { create(:proposal, :published, component: proposal_component, users: [user1]) } + let!(:proposal2) { create(:proposal, :published, component: proposal_component, users: [user1]) } + + before do + create(:proposal_vote, proposal: proposal1, author: user2) + create(:proposal_vote, proposal: proposal2, author: user2) + create(:proposal_vote, proposal: proposal1, author: user1) + end + + it "returns the vote count per user" do + result = subject.call + expect(result[user2.id]).to eq(2) + expect(result[user1.id]).to eq(1) + end + end + end +end diff --git a/spec/services/decidim/extra_user_fields/pivot_table_builder_spec.rb b/spec/services/decidim/extra_user_fields/pivot_table_builder_spec.rb new file mode 100644 index 00000000..bf0618c8 --- /dev/null +++ b/spec/services/decidim/extra_user_fields/pivot_table_builder_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::ExtraUserFields + describe PivotTableBuilder do + subject do + described_class.new( + participatory_space: participatory_process, + metric_name: "participants", + row_field: "gender", + col_field: "age_range" + ) + end + + let(:organization) { create(:organization) } + let(:participatory_process) { create(:participatory_process, :with_steps, organization:) } + let(:proposal_component) { create(:proposal_component, :published, participatory_space: participatory_process) } + + context "when there are no participants" do + it "returns an empty pivot table" do + result = subject.call + expect(result).to be_empty + end + end + + context "when there are participants with extended data" do + let(:user_female_young) do + create(:user, :confirmed, organization:, extended_data: { "gender" => "female", "age_range" => "17_to_30" }) + end + let(:user_male_young) do + create(:user, :confirmed, organization:, extended_data: { "gender" => "male", "age_range" => "17_to_30" }) + end + let(:user_female_old) do + create(:user, :confirmed, organization:, extended_data: { "gender" => "female", "age_range" => "61_or_more" }) + end + let(:user_no_data) do + create(:user, :confirmed, organization:, extended_data: {}) + end + + before do + create(:proposal, :published, component: proposal_component, users: [user_female_young]) + create(:proposal, :published, component: proposal_component, users: [user_male_young]) + create(:proposal, :published, component: proposal_component, users: [user_female_old]) + create(:proposal, :published, component: proposal_component, users: [user_no_data]) + end + + it "builds a pivot table with correct dimensions" do + result = subject.call + expect(result.row_values).to include("female", "male", "non_specified") + expect(result.col_values).to include("17_to_30", "61_or_more", "non_specified") + end + + it "fills cells with correct counts" do + result = subject.call + expect(result.cell("female", "17_to_30")).to eq(1) + expect(result.cell("male", "17_to_30")).to eq(1) + expect(result.cell("female", "61_or_more")).to eq(1) + expect(result.cell("non_specified", "non_specified")).to eq(1) + end + + it "calculates correct totals" do + result = subject.call + expect(result.grand_total).to eq(4) + expect(result.row_total("female")).to eq(2) + expect(result.col_total("17_to_30")).to eq(2) + end + end + + context "with an invalid metric name" do + subject do + described_class.new( + participatory_space: participatory_process, + metric_name: "invalid", + row_field: "gender", + col_field: "age_range" + ) + end + + it "returns an empty pivot table" do + result = subject.call + expect(result).to be_empty + end + end + end +end diff --git a/spec/system/admin_manages_officializations_spec.rb b/spec/system/admin_manages_officializations_spec.rb index 815ef71d..93a1b695 100644 --- a/spec/system/admin_manages_officializations_spec.rb +++ b/spec/system/admin_manages_officializations_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" -describe "Admin manages officializations" do # rubocop:disable RSpec/DescribeClass +describe "Admin manages officializations" do include_context "with filterable context" let(:model_name) { Decidim::User.model_name } diff --git a/spec/system/admin_manages_organization_extra_user_fields_spec.rb b/spec/system/admin_manages_organization_extra_user_fields_spec.rb index de16fb35..914d0e5c 100644 --- a/spec/system/admin_manages_organization_extra_user_fields_spec.rb +++ b/spec/system/admin_manages_organization_extra_user_fields_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" -describe "Admin manages organization extra user fields" do # rubocop:disable RSpec/DescribeClass +describe "Admin manages organization extra user fields" do let(:organization) { create(:organization) } let(:user) { create(:user, :admin, :confirmed, organization:) } diff --git a/spec/system/registration_spec.rb b/spec/system/registration_spec.rb index 81e8c3b7..adae206d 100644 --- a/spec/system/registration_spec.rb +++ b/spec/system/registration_spec.rb @@ -23,7 +23,7 @@ def fill_extra_user_fields fill_in :registration_user_location, with: "Cahors" end -describe "Extra user fields" do # rubocop:disable RSpec/DescribeClass +describe "Extra user fields" do shared_examples_for "mandatory extra user fields" do |field| it "displays #{field} as mandatory" do within "label[for='registration_user_#{field}']" do From fadfc870e1d00b71248f574e63c84e244be01dea Mon Sep 17 00:00:00 2001 From: Anna Topalidi Date: Wed, 25 Feb 2026 14:12:59 +0100 Subject: [PATCH 03/15] add views --- .../admin/insights_controller.rb | 49 +++++- .../admin/insights_helper.rb | 91 ++++++++++ .../decidim_extra_user_fields.scss | 1 + .../decidim/extra_user_fields/insights.scss | 157 ++++++++++++++++++ .../extra_user_fields/metrics/base_metric.rb | 42 ++--- .../metrics/participants_metric.rb | 14 +- .../extra_user_fields/pivot_table_builder.rb | 10 +- .../admin/insights/_legend.html.erb | 15 ++ .../admin/insights/_pivot_table.html.erb | 64 +++++++ .../admin/insights/_selectors.html.erb | 7 + .../admin/insights/show.html.erb | 14 +- config/locales/en.yml | 24 +++ lib/decidim/extra_user_fields.rb | 8 + .../metrics/participants_metric_spec.rb | 19 +++ .../pivot_table_builder_spec.rb | 6 +- 15 files changed, 483 insertions(+), 38 deletions(-) create mode 100644 app/helpers/decidim/extra_user_fields/admin/insights_helper.rb create mode 100644 app/packs/stylesheets/decidim/extra_user_fields/insights.scss create mode 100644 app/views/decidim/extra_user_fields/admin/insights/_legend.html.erb create mode 100644 app/views/decidim/extra_user_fields/admin/insights/_pivot_table.html.erb create mode 100644 app/views/decidim/extra_user_fields/admin/insights/_selectors.html.erb diff --git a/app/controllers/decidim/extra_user_fields/admin/insights_controller.rb b/app/controllers/decidim/extra_user_fields/admin/insights_controller.rb index 50f6232f..e14777d1 100644 --- a/app/controllers/decidim/extra_user_fields/admin/insights_controller.rb +++ b/app/controllers/decidim/extra_user_fields/admin/insights_controller.rb @@ -5,8 +5,12 @@ module ExtraUserFields module Admin class InsightsController < Decidim::Admin::ApplicationController include Decidim::Admin::ParticipatorySpaceAdminContext + helper InsightsHelper layout :layout + helper_method :pivot_table, :current_metric, :current_row_field, :current_col_field, + :available_metrics, :available_fields + before_action :set_breadcrumbs def show @@ -15,14 +19,53 @@ def show private + def pivot_table + @pivot_table ||= PivotTableBuilder.new( + participatory_space: current_participatory_space, + metric_name: current_metric, + row_field: current_row_field, + col_field: current_col_field + ).call + end + + def current_metric + @current_metric ||= begin + metric = params[:metric].to_s + metric if InsightMetrics.valid_metric?(metric) + end || available_metrics.first + end + + def current_row_field + @current_row_field ||= validated_field(params[:rows]) || available_fields.first + end + + def current_col_field + @current_col_field ||= validated_field(params[:cols]) || available_fields.second + end + + def available_metrics + @available_metrics ||= InsightMetrics.available_metrics + end + + def available_fields + @available_fields ||= Decidim::ExtraUserFields.insight_fields + end + + def validated_field(value) + field = value.to_s + field if available_fields.include?(field) + end + def permission_class_chain [::Decidim::ExtraUserFields::Admin::Permissions] + super end def current_participatory_space - @current_participatory_space ||= - Decidim::ParticipatoryProcess.find_by(organization: current_organization, slug: params[:participatory_process_slug]) || - Decidim::Assembly.find_by!(organization: current_organization, slug: params[:assembly_slug]) + @current_participatory_space ||= if params[:participatory_process_slug] + Decidim::ParticipatoryProcess.find_by!(organization: current_organization, slug: params[:participatory_process_slug]) + else + Decidim::Assembly.find_by!(organization: current_organization, slug: params[:assembly_slug]) + end end def set_breadcrumbs diff --git a/app/helpers/decidim/extra_user_fields/admin/insights_helper.rb b/app/helpers/decidim/extra_user_fields/admin/insights_helper.rb new file mode 100644 index 00000000..d9aa3b58 --- /dev/null +++ b/app/helpers/decidim/extra_user_fields/admin/insights_helper.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module Decidim + module ExtraUserFields + module Admin + module InsightsHelper + # Render a compact selector: bordered frame with "Label: [value ▾]". + # Yields each option to the block for label generation. + def insight_selector_field(param_name, options, selected_value, &block) + label_key = "decidim.admin.extra_user_fields.insights.selectors.#{param_name}" + + content_tag(:div, class: "insights-selectors__field") do + content_tag(:span, t(label_key), class: "insights-selectors__label") + + select_tag( + param_name, + options_for_select(options.map { |opt| [block.call(opt), opt] }, selected_value), + class: "insights-selectors__select", + onchange: "this.form.submit();" + ) + end + end + + # Translate a metric name for display. + def metric_label(metric_name) + t("decidim.admin.extra_user_fields.insights.metrics.#{metric_name}", + default: metric_name.humanize) + end + + # Translate a field name for display. + def field_label(field_name) + t("decidim.admin.extra_user_fields.insights.fields.#{field_name}", + default: field_name.humanize) + end + + # Translate a field value for display. + # Tries field-specific i18n keys first (e.g., genders.female), falls back to humanize. + def field_value_label(field_name, value) + return t("decidim.admin.extra_user_fields.insights.non_specified") if value.nil? + + key = i18n_key_for_field_value(field_name, value) + t(key, default: value.humanize) + end + + # Inline style for a data cell's heatmap coloring. + # Uses gray gradient for rows/columns where the field value is missing. + def cell_style(value, max_value, row, col) + if row.nil? || col.nil? + heatmap_color(value, max_value, gray: true) + else + heatmap_color(value, max_value) + end + end + + private + + def i18n_key_for_field_value(field_name, value) + case field_name.to_s + when "gender" + "decidim.extra_user_fields.genders.#{value}" + when "age_range" + "decidim.extra_user_fields.age_ranges.#{value}" + else + "decidim.admin.extra_user_fields.insights.field_values.#{field_name}.#{value}" + end + end + + # Compute inline heatmap color for a cell value. + # When gray: true, uses a neutral gray gradient (for non-specified values). + # Otherwise uses a yellow → red gradient. + def heatmap_color(value, max_value, gray: false) + return "" if max_value.zero? || value.zero? + + intensity = value.to_f / max_value + text_color = intensity > 0.65 ? "#fff" : "#1a1a1a" + + bg = if gray + lightness = (95 - (intensity * 35)).round + "hsl(0, 0%, #{lightness}%)" + else + hue = (50 * (1 - intensity)).round + saturation = (90 + (intensity * 10)).round + lightness = (90 - (intensity * 45)).round + "hsl(#{hue}, #{saturation}%, #{lightness}%)" + end + + "background-color: #{bg}; color: #{text_color};" + end + end + end + end +end diff --git a/app/packs/entrypoints/decidim_extra_user_fields.scss b/app/packs/entrypoints/decidim_extra_user_fields.scss index 6b0fef8c..b34cae0e 100644 --- a/app/packs/entrypoints/decidim_extra_user_fields.scss +++ b/app/packs/entrypoints/decidim_extra_user_fields.scss @@ -1 +1,2 @@ @import "stylesheets/decidim/extra_user_fields/signup_form"; +@import "stylesheets/decidim/extra_user_fields/insights"; diff --git a/app/packs/stylesheets/decidim/extra_user_fields/insights.scss b/app/packs/stylesheets/decidim/extra_user_fields/insights.scss new file mode 100644 index 00000000..2f0033fe --- /dev/null +++ b/app/packs/stylesheets/decidim/extra_user_fields/insights.scss @@ -0,0 +1,157 @@ +// Insights page styles for the participatory space admin panel. + +// Selectors — compact inline controls: "Label: [value ▾]" +.insights-selectors { + display: flex; + gap: 1.25rem; + flex-wrap: wrap; + align-items: center; + justify-content: center; + + &__field { + display: flex; + align-items: center; + border: 1px solid #d1d5db; + border-radius: 6px; + background: #fff; + overflow: hidden; + } + + &__label { + padding: 0.5rem 0 0.5rem 0.75rem; + font-weight: 600; + font-size: 0.875rem; + color: #374151; + white-space: nowrap; + + &::after { + content: ":"; + } + } + + &__select { + border: none; + background: transparent; + padding: 0.5rem 0.75rem 0.5rem 0.375rem; + font-size: 0.875rem; + color: #6b7280; + cursor: pointer; + outline: none; + appearance: auto; + + &:focus { + color: #111827; + } + } +} + +// Pivot table +.insights-pivot-table { + overflow-x: auto; + + .card-section { + padding: 0; + } +} + +.insights-table { + width: 100%; + border-collapse: collapse; + + th, + td { + padding: 0.75rem; + } + + // Header + &__corner-header, + &__col-header, + &__row-total-header { + border-bottom: 2px solid #e5e5e5; + } + + &__corner-header { + text-align: left; + } + + &__col-header, + &__row-total-header { + text-align: right; + } + + &__row-total-header { + font-weight: bold; + } + + // Body + &__row-header { + font-weight: 600; + border-bottom: 1px solid #f0f0f0; + } + + &__cell { + text-align: right; + border-bottom: 1px solid #f0f0f0; + } + + &__row-total { + text-align: right; + font-weight: bold; + border-bottom: 1px solid #f0f0f0; + background-color: #f8f8f8; + } + + // Footer + &__footer-label, + &__col-total, + &__grand-total { + font-weight: bold; + border-top: 2px solid #e5e5e5; + } + + &__col-total, + &__grand-total { + text-align: right; + background-color: #f8f8f8; + } + + &__grand-total { + background-color: #f0f0f0; + } +} + +// Legend +.insights-legend { + display: flex; + align-items: center; + gap: 0.75rem; + margin-top: 0.75rem; + font-size: 0.85rem; + color: #666; + + &__gradient { + display: flex; + height: 16px; + border-radius: 3px; + overflow: hidden; + width: 160px; + + &-step { + flex: 1; + } + } + + &__non-specified { + margin-left: 1rem; + color: #999; + } + + &__non-specified-swatch { + display: inline-block; + width: 16px; + height: 16px; + background-color: hsl(0, 0%, 80%); + border-radius: 3px; + vertical-align: middle; + } +} diff --git a/app/queries/decidim/extra_user_fields/metrics/base_metric.rb b/app/queries/decidim/extra_user_fields/metrics/base_metric.rb index 65a08946..02e9c911 100644 --- a/app/queries/decidim/extra_user_fields/metrics/base_metric.rb +++ b/app/queries/decidim/extra_user_fields/metrics/base_metric.rb @@ -34,25 +34,7 @@ def budget_ids # Find comments on resources within this space. # Returns an ActiveRecord scope (not plucked). def comments_in_space - scopes = [] - - if proposal_ids.any? - scopes << Decidim::Comments::Comment.where( - decidim_root_commentable_type: "Decidim::Proposals::Proposal", - decidim_root_commentable_id: proposal_ids - ) - end - - if budget_project_ids.any? - scopes << Decidim::Comments::Comment.where( - decidim_root_commentable_type: "Decidim::Budgets::Project", - decidim_root_commentable_id: budget_project_ids - ) - end - - return Decidim::Comments::Comment.none if scopes.empty? - - scopes.reduce(:or) + @comments_in_space ||= build_comments_scope end def budget_project_ids @@ -86,6 +68,28 @@ def fetch_budget_project_ids .where(decidim_budgets_budget_id: budget_ids) .pluck(:id) end + + def build_comments_scope + scopes = [] + + if proposal_ids.any? + scopes << Decidim::Comments::Comment.where( + decidim_root_commentable_type: "Decidim::Proposals::Proposal", + decidim_root_commentable_id: proposal_ids + ) + end + + if budget_project_ids.any? + scopes << Decidim::Comments::Comment.where( + decidim_root_commentable_type: "Decidim::Budgets::Project", + decidim_root_commentable_id: budget_project_ids + ) + end + + return Decidim::Comments::Comment.none if scopes.empty? + + scopes.reduce(:or) + end end end end diff --git a/app/queries/decidim/extra_user_fields/metrics/participants_metric.rb b/app/queries/decidim/extra_user_fields/metrics/participants_metric.rb index f6aa0a9d..79eeb123 100644 --- a/app/queries/decidim/extra_user_fields/metrics/participants_metric.rb +++ b/app/queries/decidim/extra_user_fields/metrics/participants_metric.rb @@ -3,13 +3,15 @@ module Decidim module ExtraUserFields module Metrics - # Counts unique participants — users who authored any resource (proposal, comment, budget vote) - # within the given participatory space. Each user is counted once. + # Counts unique participants — users who have any activity in the participatory space. + # Includes: proposal authors, proposal supporters, commenters, budget voters. + # Each user is counted once regardless of how many activities they have. class ParticipantsMetric < BaseMetric def call user_ids = Set.new user_ids.merge(proposal_author_ids) + user_ids.merge(proposal_supporter_ids) user_ids.merge(comment_author_ids) user_ids.merge(budget_voter_ids) @@ -28,6 +30,14 @@ def proposal_author_ids .distinct.pluck(:decidim_author_id) end + def proposal_supporter_ids + return [] if proposal_ids.empty? + + Decidim::Proposals::ProposalVote + .where(decidim_proposal_id: proposal_ids) + .distinct.pluck(:decidim_author_id) + end + def comment_author_ids comments_in_space .where(decidim_author_type: "Decidim::UserBaseEntity") diff --git a/app/services/decidim/extra_user_fields/pivot_table_builder.rb b/app/services/decidim/extra_user_fields/pivot_table_builder.rb index 17956581..b0dc7545 100644 --- a/app/services/decidim/extra_user_fields/pivot_table_builder.rb +++ b/app/services/decidim/extra_user_fields/pivot_table_builder.rb @@ -7,8 +7,6 @@ module ExtraUserFields # 2. Loading those users and reading their extended_data for row/col fields # 3. Aggregating counts into a cross-tabulation matrix class PivotTableBuilder - NON_SPECIFIED = "non_specified" - # @param participatory_space [Decidim::ParticipatoryProcess, Decidim::Assembly] # @param metric_name [String] key from InsightMetrics::REGISTRY # @param row_field [String] extra user field name for the Y axis @@ -67,18 +65,16 @@ def build_cells(metric_data, users) end def extract_field(extended_data, field) - value = extended_data[field] - value = value.presence - value || NON_SPECIFIED + extended_data[field].presence end def empty_pivot_table PivotTable.new(row_values: [], col_values: [], cells: {}) end - # Sort values alphabetically but push "non_specified" to the end. + # Sort values alphabetically, nil (non-specified) goes last. def sort_key(value) - value == NON_SPECIFIED ? [1, ""] : [0, value.to_s] + value.nil? ? [1, ""] : [0, value.to_s] end end end diff --git a/app/views/decidim/extra_user_fields/admin/insights/_legend.html.erb b/app/views/decidim/extra_user_fields/admin/insights/_legend.html.erb new file mode 100644 index 00000000..3489905a --- /dev/null +++ b/app/views/decidim/extra_user_fields/admin/insights/_legend.html.erb @@ -0,0 +1,15 @@ +
+ <%= t("decidim.admin.extra_user_fields.insights.legend.fewer") %> +
+
+
+
+
+
+
+ <%= t("decidim.admin.extra_user_fields.insights.legend.more") %> + + + <%= t("decidim.admin.extra_user_fields.insights.legend.non_specified") %> + +
diff --git a/app/views/decidim/extra_user_fields/admin/insights/_pivot_table.html.erb b/app/views/decidim/extra_user_fields/admin/insights/_pivot_table.html.erb new file mode 100644 index 00000000..cef2bed0 --- /dev/null +++ b/app/views/decidim/extra_user_fields/admin/insights/_pivot_table.html.erb @@ -0,0 +1,64 @@ +<% if pivot_table.empty? %> +
+
+

<%= t("decidim.admin.extra_user_fields.insights.no_data") %>

+
+
+<% else %> +
+
+ + + + + <% pivot_table.col_values.each do |col| %> + + <% end %> + + + + + <% pivot_table.row_values.each do |row| %> + + + <% pivot_table.col_values.each do |col| %> + <% value = pivot_table.cell(row, col) %> + + <% end %> + + + <% end %> + + + + + <% pivot_table.col_values.each do |col| %> + + <% end %> + + + +
+ <%= field_label(current_row_field) %> \ <%= field_label(current_col_field) %> + + <%= field_value_label(current_col_field, col) %> + + <%= t("decidim.admin.extra_user_fields.insights.row_total") %> +
+ <%= field_value_label(current_row_field, row) %> + + <%= number_with_delimiter(value) %> + + <%= number_with_delimiter(pivot_table.row_total(row)) %> +
+ <%= number_with_delimiter(pivot_table.col_total(col)) %> + + <%= number_with_delimiter(pivot_table.grand_total) %> +
+
+
+ + <%= render partial: "decidim/extra_user_fields/admin/insights/legend" %> +<% end %> diff --git a/app/views/decidim/extra_user_fields/admin/insights/_selectors.html.erb b/app/views/decidim/extra_user_fields/admin/insights/_selectors.html.erb new file mode 100644 index 00000000..01e3df5b --- /dev/null +++ b/app/views/decidim/extra_user_fields/admin/insights/_selectors.html.erb @@ -0,0 +1,7 @@ +<%= form_tag(request.path, method: :get) do %> +
+ <%= insight_selector_field(:rows, available_fields, current_row_field) { |f| field_label(f) } %> + <%= insight_selector_field(:cols, available_fields, current_col_field) { |f| field_label(f) } %> + <%= insight_selector_field(:metric, available_metrics, current_metric) { |m| metric_label(m) } %> +
+<% end %> diff --git a/app/views/decidim/extra_user_fields/admin/insights/show.html.erb b/app/views/decidim/extra_user_fields/admin/insights/show.html.erb index 105c2cb5..cddc9f6f 100644 --- a/app/views/decidim/extra_user_fields/admin/insights/show.html.erb +++ b/app/views/decidim/extra_user_fields/admin/insights/show.html.erb @@ -1,11 +1,17 @@ +<% append_stylesheet_pack_tag "decidim_extra_user_fields_css" %> <% add_decidim_page_title(t("decidim.admin.extra_user_fields.insights.title")) %>

<%= t("decidim.admin.extra_user_fields.insights.title") %>

-
-
-

<%= t("decidim.admin.extra_user_fields.insights.description") %>

-
+ +
+

<%= t("decidim.admin.extra_user_fields.insights.description") %>

+
+ +<%= render partial: "decidim/extra_user_fields/admin/insights/selectors" %> + +
+ <%= render partial: "decidim/extra_user_fields/admin/insights/pivot_table" %>
diff --git a/config/locales/en.yml b/config/locales/en.yml index a71132d0..d60b2d68 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -26,9 +26,33 @@ en: export_as: Export %{export_format} extra_user_fields: insights: + column_total: Column Total description: Explore participant activity across profile dimensions. Select a metric and two profile fields to see how participation is distributed. + fields: + age_range: Age span + country: Country + gender: Gender + location: Location + postal_code: Postal code + legend: + fewer: Fewer + more: More + non_specified: Non specified menu_title: Insights + metrics: + budget_votes: Budget votes + comments: Comments + participants: Participants + proposals_created: Proposals created + proposals_supported: Proposals supported + no_data: No participation data found for this space with the selected criteria. + non_specified: Non specified + row_total: Row Total + selectors: + cols: Columns (X axis) + metric: Metric + rows: Rows (Y axis) title: Participatory Space Insights menu: title: Manage extra user fields diff --git a/lib/decidim/extra_user_fields.rb b/lib/decidim/extra_user_fields.rb index 012227ea..5b49d0b7 100644 --- a/lib/decidim/extra_user_fields.rb +++ b/lib/decidim/extra_user_fields.rb @@ -78,6 +78,14 @@ module ExtraUserFields } end + # Extra user fields allowed as pivot table axes in the Insights page. + # Only categorical fields with limited unique values make sense here. + # Override via initializer: + # Decidim::ExtraUserFields.config.insight_fields = %w(gender age_range country) + config_accessor :insight_fields do + %w(gender age_range country postal_code location) + end + # Registry of insight metrics available for pivot tables. # Keys are metric identifiers, values are fully-qualified class names. # Custom metrics can be added via an initializer: diff --git a/spec/queries/decidim/extra_user_fields/metrics/participants_metric_spec.rb b/spec/queries/decidim/extra_user_fields/metrics/participants_metric_spec.rb index da0c2ffe..353b3d86 100644 --- a/spec/queries/decidim/extra_user_fields/metrics/participants_metric_spec.rb +++ b/spec/queries/decidim/extra_user_fields/metrics/participants_metric_spec.rb @@ -44,6 +44,25 @@ module Decidim::ExtraUserFields::Metrics end end + context "when users supported proposals" do + let!(:proposal) { create(:proposal, :published, component: proposal_component, users: [user1]) } + + before do + create(:proposal_vote, proposal: proposal, author: user2) + end + + it "includes the supporter" do + result = subject.call + expect(result[user2.id]).to eq(1) + end + + it "does not double-count a user who also authored" do + create(:proposal_vote, proposal: proposal, author: user1) + result = subject.call + expect(result[user1.id]).to eq(1) + end + end + context "when users voted on budgets" do let(:budgets_component) { create(:budgets_component, :published, participatory_space: participatory_process) } let(:budget) { create(:budget, component: budgets_component) } diff --git a/spec/services/decidim/extra_user_fields/pivot_table_builder_spec.rb b/spec/services/decidim/extra_user_fields/pivot_table_builder_spec.rb index bf0618c8..1c703bca 100644 --- a/spec/services/decidim/extra_user_fields/pivot_table_builder_spec.rb +++ b/spec/services/decidim/extra_user_fields/pivot_table_builder_spec.rb @@ -47,8 +47,8 @@ module Decidim::ExtraUserFields it "builds a pivot table with correct dimensions" do result = subject.call - expect(result.row_values).to include("female", "male", "non_specified") - expect(result.col_values).to include("17_to_30", "61_or_more", "non_specified") + expect(result.row_values).to include("female", "male", nil) + expect(result.col_values).to include("17_to_30", "61_or_more", nil) end it "fills cells with correct counts" do @@ -56,7 +56,7 @@ module Decidim::ExtraUserFields expect(result.cell("female", "17_to_30")).to eq(1) expect(result.cell("male", "17_to_30")).to eq(1) expect(result.cell("female", "61_or_more")).to eq(1) - expect(result.cell("non_specified", "non_specified")).to eq(1) + expect(result.cell(nil, nil)).to eq(1) end it "calculates correct totals" do From a080c26784769a38247a5f44d4f82fea83400716 Mon Sep 17 00:00:00 2001 From: Anna Topalidi Date: Wed, 25 Feb 2026 14:30:40 +0100 Subject: [PATCH 04/15] fix heatmap --- .../admin/insights_helper.rb | 19 +++++++++++++++---- .../decidim/extra_user_fields/pivot_table.rb | 19 +++++++++++++++++++ .../decidim/extra_user_fields/insights.scss | 2 -- .../admin/insights/_pivot_table.html.erb | 6 +++--- 4 files changed, 37 insertions(+), 9 deletions(-) diff --git a/app/helpers/decidim/extra_user_fields/admin/insights_helper.rb b/app/helpers/decidim/extra_user_fields/admin/insights_helper.rb index d9aa3b58..865c3dab 100644 --- a/app/helpers/decidim/extra_user_fields/admin/insights_helper.rb +++ b/app/helpers/decidim/extra_user_fields/admin/insights_helper.rb @@ -42,15 +42,26 @@ def field_value_label(field_name, value) end # Inline style for a data cell's heatmap coloring. - # Uses gray gradient for rows/columns where the field value is missing. - def cell_style(value, max_value, row, col) + # Colored cells normalize against max of specified (non-nil) cells. + # Gray cells normalize against the overall max. + def cell_style(value, pivot_table, row, col) if row.nil? || col.nil? - heatmap_color(value, max_value, gray: true) + heatmap_color(value, pivot_table.max_value, gray: true) else - heatmap_color(value, max_value) + heatmap_color(value, pivot_table.max_specified_value) end end + # Heatmap style for Row Total cells (normalized among row totals). + def row_total_style(value, pivot_table) + heatmap_color(value, pivot_table.max_row_total) + end + + # Heatmap style for Column Total cells (normalized among col totals). + def col_total_style(value, pivot_table) + heatmap_color(value, pivot_table.max_col_total) + end + private def i18n_key_for_field_value(field_name, value) diff --git a/app/models/decidim/extra_user_fields/pivot_table.rb b/app/models/decidim/extra_user_fields/pivot_table.rb index 64398007..bcc276a7 100644 --- a/app/models/decidim/extra_user_fields/pivot_table.rb +++ b/app/models/decidim/extra_user_fields/pivot_table.rb @@ -48,6 +48,25 @@ def max_value @max_value ||= cells.values.flat_map(&:values).max || 0 end + # Max among cells where both row and col are non-nil. + # Used for heatmap normalization of colored (non-gray) cells. + def max_specified_value + @max_specified_value ||= begin + values = row_values.compact.flat_map do |row| + col_values.compact.map { |col| cell(row, col) } + end + values.max || 0 + end + end + + def max_row_total + @max_row_total ||= row_totals.values.max || 0 + end + + def max_col_total + @max_col_total ||= col_totals.values.max || 0 + end + def empty? grand_total.zero? end diff --git a/app/packs/stylesheets/decidim/extra_user_fields/insights.scss b/app/packs/stylesheets/decidim/extra_user_fields/insights.scss index 2f0033fe..4576f9a5 100644 --- a/app/packs/stylesheets/decidim/extra_user_fields/insights.scss +++ b/app/packs/stylesheets/decidim/extra_user_fields/insights.scss @@ -98,7 +98,6 @@ text-align: right; font-weight: bold; border-bottom: 1px solid #f0f0f0; - background-color: #f8f8f8; } // Footer @@ -112,7 +111,6 @@ &__col-total, &__grand-total { text-align: right; - background-color: #f8f8f8; } &__grand-total { diff --git a/app/views/decidim/extra_user_fields/admin/insights/_pivot_table.html.erb b/app/views/decidim/extra_user_fields/admin/insights/_pivot_table.html.erb index cef2bed0..cd0844f6 100644 --- a/app/views/decidim/extra_user_fields/admin/insights/_pivot_table.html.erb +++ b/app/views/decidim/extra_user_fields/admin/insights/_pivot_table.html.erb @@ -31,11 +31,11 @@ <% pivot_table.col_values.each do |col| %> <% value = pivot_table.cell(row, col) %> - + <%= number_with_delimiter(value) %> <% end %> - + <%= number_with_delimiter(pivot_table.row_total(row)) %> @@ -47,7 +47,7 @@ <%= t("decidim.admin.extra_user_fields.insights.column_total") %> <% pivot_table.col_values.each do |col| %> - + <%= number_with_delimiter(pivot_table.col_total(col)) %> <% end %> From 0bcfaae149348fa2206baa2d75a3b48157a2753f Mon Sep 17 00:00:00 2001 From: Anna Topalidi Date: Wed, 25 Feb 2026 15:35:43 +0100 Subject: [PATCH 05/15] add specs --- .../admin/insights_helper_spec.rb | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 spec/helpers/decidim/extra_user_fields/admin/insights_helper_spec.rb diff --git a/spec/helpers/decidim/extra_user_fields/admin/insights_helper_spec.rb b/spec/helpers/decidim/extra_user_fields/admin/insights_helper_spec.rb new file mode 100644 index 00000000..ca3abb70 --- /dev/null +++ b/spec/helpers/decidim/extra_user_fields/admin/insights_helper_spec.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::ExtraUserFields::Admin + describe InsightsHelper do + describe "#metric_label" do + it "translates known metric names" do + expect(helper.metric_label("participants")).to eq("Participants") + expect(helper.metric_label("proposals_created")).to eq("Proposals created") + expect(helper.metric_label("proposals_supported")).to eq("Proposals supported") + expect(helper.metric_label("comments")).to eq("Comments") + expect(helper.metric_label("budget_votes")).to eq("Budget votes") + end + + it "humanizes unknown metric names" do + expect(helper.metric_label("unknown_metric")).to eq("Unknown metric") + end + end + + describe "#field_label" do + it "translates known field names" do + expect(helper.field_label("gender")).to eq("Gender") + expect(helper.field_label("age_range")).to eq("Age span") + expect(helper.field_label("country")).to eq("Country") + expect(helper.field_label("postal_code")).to eq("Postal code") + expect(helper.field_label("location")).to eq("Location") + end + + it "humanizes unknown field names" do + expect(helper.field_label("unknown_field")).to eq("Unknown field") + end + end + + describe "#field_value_label" do + it "returns 'Non specified' for nil values" do + expect(helper.field_value_label("gender", nil)).to eq("Non specified") + end + + it "translates gender values via genders namespace" do + expect(helper.field_value_label("gender", "female")).to eq("Female") + expect(helper.field_value_label("gender", "male")).to eq("Male") + expect(helper.field_value_label("gender", "other")).to eq("Other") + end + + it "translates age_range values via age_ranges namespace" do + expect(helper.field_value_label("age_range", "17_to_30")).to eq("17 to 30") + expect(helper.field_value_label("age_range", "61_or_more")).to eq("61 or older") + expect(helper.field_value_label("age_range", "up_to_16")).to eq("16 or younger") + end + + it "falls back to humanized value for other fields" do + expect(helper.field_value_label("country", "spain")).to eq("Spain") + expect(helper.field_value_label("location", "some_place")).to eq("Some place") + end + end + + describe "#cell_style" do + let(:pivot_table) do + Decidim::ExtraUserFields::PivotTable.new( + row_values: ["female", "male", nil], + col_values: ["young", nil], + cells: { + "female" => { "young" => 10 }, + "male" => { "young" => 5 }, + nil => { nil => 3 } + } + ) + end + + it "returns empty string when value is zero" do + expect(helper.cell_style(0, pivot_table, "female", "young")).to eq("") + end + + it "returns colored gradient for specified cells" do + result = helper.cell_style(10, pivot_table, "female", "young") + expect(result).to include("background-color:") + # Max intensity produces hue=0 (red end of yellow-to-red gradient) + expect(result).to include("hsl(0, 100%, 45%)") + end + + it "returns gray gradient when row is nil" do + result = helper.cell_style(3, pivot_table, nil, nil) + expect(result).to include("hsl(0, 0%,") + end + + it "returns gray gradient when col is nil" do + result = helper.cell_style(5, pivot_table, "female", nil) + expect(result).to include("hsl(0, 0%,") + end + end + + describe "#row_total_style" do + let(:pivot_table) do + Decidim::ExtraUserFields::PivotTable.new( + row_values: %w(a b), + col_values: %w(x y), + cells: { "a" => { "x" => 10, "y" => 5 }, "b" => { "x" => 3, "y" => 2 } } + ) + end + + it "returns empty string when value is zero" do + expect(helper.row_total_style(0, pivot_table)).to eq("") + end + + it "returns hsl gradient for non-zero values" do + result = helper.row_total_style(15, pivot_table) + expect(result).to include("background-color: hsl(") + end + end + + describe "#col_total_style" do + let(:pivot_table) do + Decidim::ExtraUserFields::PivotTable.new( + row_values: %w(a b), + col_values: %w(x y), + cells: { "a" => { "x" => 10, "y" => 5 }, "b" => { "x" => 3, "y" => 2 } } + ) + end + + it "returns empty string when value is zero" do + expect(helper.col_total_style(0, pivot_table)).to eq("") + end + + it "returns hsl gradient for non-zero values" do + result = helper.col_total_style(13, pivot_table) + expect(result).to include("background-color: hsl(") + end + end + + describe "#insight_selector_field" do + it "renders a selector with label and select tag" do + result = helper.insight_selector_field(:rows, %w(gender age_range), "gender", &:humanize) + + expect(result).to include('class="insights-selectors__field"') + expect(result).to include("Rows (Y axis)") + expect(result).to include(" Date: Thu, 26 Feb 2026 11:27:16 +0100 Subject: [PATCH 06/15] add specs --- config/locales/fr.yml | 33 ++- .../extra_user_fields/pivot_table_spec.rb | 65 +++++ spec/permissions/admin/permissions_spec.rb | 30 +-- .../pivot_table_builder_spec.rb | 34 +-- spec/system/admin_views_insights_spec.rb | 247 +++++++++++++++++- 5 files changed, 347 insertions(+), 62 deletions(-) diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 32842e0d..c871e882 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -26,6 +26,37 @@ fr: exports: export_as: Exporter au format %{export_format} extra_user_fields: + insights: + column_total: Total colonne + description: Explorez l'activité des participants selon les dimensions de + profil. Sélectionnez une métrique et deux champs de profil pour voir comment + la participation est répartie. + fields: + age_range: Tranche d'âge + country: Pays + gender: Genre + location: Localisation + postal_code: Code postal + legend: + fewer: Moins + more: Plus + non_specified: Non spécifié + menu_title: Aperçus + metrics: + budget_votes: Votes budgétaires + comments: Commentaires + participants: Participants + proposals_created: Propositions créées + proposals_supported: Propositions soutenues + no_data: Aucune donnée de participation trouvée pour cet espace avec les + critères sélectionnés. + non_specified: Non spécifié + row_total: Total ligne + selectors: + cols: Colonnes (axe X) + metric: Métrique + rows: Lignes (axe Y) + title: Aperçus de l'espace participatif menu: title: Gérer les champs d'inscription personnalisés components: @@ -111,7 +142,7 @@ fr: label: Activer le champ slogan update: failure: Une erreur est survenue lors de la mise à jour - success: Les champs d'inscription ont été mis à jour avec succ§s + success: Les champs d'inscription ont été mis à jour avec succès age_ranges: 17_to_30: 17 à 30 31_to_60: 31 à 60 diff --git a/spec/models/decidim/extra_user_fields/pivot_table_spec.rb b/spec/models/decidim/extra_user_fields/pivot_table_spec.rb index f3baa7ca..c12bd249 100644 --- a/spec/models/decidim/extra_user_fields/pivot_table_spec.rb +++ b/spec/models/decidim/extra_user_fields/pivot_table_spec.rb @@ -52,6 +52,71 @@ module Decidim::ExtraUserFields end end + describe "#max_specified_value" do + context "when all rows and cols are non-nil" do + it "returns the highest cell value" do + expect(pivot_table.max_specified_value).to eq(20) + end + end + + context "when some rows or cols are nil" do + let(:row_values) { ["18_to_25", nil] } + let(:col_values) { ["female", nil] } + let(:cells) do + { + "18_to_25" => { "female" => 5, nil => 99 }, + nil => { "female" => 88, nil => 77 } + } + end + + it "excludes cells where row or col is nil" do + expect(pivot_table.max_specified_value).to eq(5) + end + end + + context "when there are no non-nil combinations" do + let(:row_values) { [nil] } + let(:col_values) { [nil] } + let(:cells) { { nil => { nil => 10 } } } + + it "returns 0" do + expect(pivot_table.max_specified_value).to eq(0) + end + end + end + + describe "#max_row_total" do + it "returns the highest row total" do + expect(pivot_table.max_row_total).to eq(35) + end + + context "when there are no cells" do + let(:row_values) { [] } + let(:col_values) { [] } + let(:cells) { {} } + + it "returns 0" do + expect(pivot_table.max_row_total).to eq(0) + end + end + end + + describe "#max_col_total" do + it "returns the highest column total" do + expect(pivot_table.max_col_total).to eq(30) + end + + context "when there are no cells" do + let(:row_values) { [] } + let(:col_values) { [] } + let(:cells) { {} } + + it "returns 0" do + expect(pivot_table.max_col_total).to eq(0) + end + end + end + describe "#empty?" do it "returns false when there is data" do expect(pivot_table.empty?).to be(false) diff --git a/spec/permissions/admin/permissions_spec.rb b/spec/permissions/admin/permissions_spec.rb index 8edac298..668c87a2 100644 --- a/spec/permissions/admin/permissions_spec.rb +++ b/spec/permissions/admin/permissions_spec.rb @@ -7,14 +7,8 @@ module Decidim::ExtraUserFields::Admin subject { described_class.new(user, permission_action, context).permissions.allowed? } let(:organization) { create(:organization) } - let(:context) do - { - current_organization: organization - } - end - let(:action) do - { scope: :admin, action: :read, subject: :extra_user_fields } - end + let(:context) { { current_organization: organization } } + let(:action) { { scope: :admin, action: :read, subject: :extra_user_fields } } let(:permission_action) { Decidim::PermissionAction.new(**action) } context "when user is admin" do @@ -23,17 +17,13 @@ module Decidim::ExtraUserFields::Admin it { is_expected.to be_truthy } context "when scope is not admin" do - let(:action) do - { scope: :foo, action: :read, subject: :extra_user_fields } - end + let(:action) { { scope: :foo, action: :read, subject: :extra_user_fields } } it_behaves_like "permission is not set" end context "when reading insights" do - let(:action) do - { scope: :admin, action: :read, subject: :insights } - end + let(:action) { { scope: :admin, action: :read, subject: :insights } } it { is_expected.to be_truthy } end @@ -43,25 +33,19 @@ module Decidim::ExtraUserFields::Admin let(:user) { create(:user, organization:) } context "and tries to read extra user fields" do - let(:action) do - { scope: :admin, action: :read, subject: :extra_user_fields } - end + let(:action) { { scope: :admin, action: :read, subject: :extra_user_fields } } it_behaves_like "permission is not set" end context "and tries to update extra user fields" do - let(:action) do - { scope: :admin, action: :update, subject: :extra_user_fields } - end + let(:action) { { scope: :admin, action: :update, subject: :extra_user_fields } } it_behaves_like "permission is not set" end context "and tries to read insights" do - let(:action) do - { scope: :admin, action: :read, subject: :insights } - end + let(:action) { { scope: :admin, action: :read, subject: :insights } } it_behaves_like "permission is not set" end diff --git a/spec/services/decidim/extra_user_fields/pivot_table_builder_spec.rb b/spec/services/decidim/extra_user_fields/pivot_table_builder_spec.rb index 1c703bca..04e52144 100644 --- a/spec/services/decidim/extra_user_fields/pivot_table_builder_spec.rb +++ b/spec/services/decidim/extra_user_fields/pivot_table_builder_spec.rb @@ -4,14 +4,7 @@ module Decidim::ExtraUserFields describe PivotTableBuilder do - subject do - described_class.new( - participatory_space: participatory_process, - metric_name: "participants", - row_field: "gender", - col_field: "age_range" - ) - end + subject { described_class.new(participatory_space: participatory_process, metric_name: "participants", row_field: "gender", col_field: "age_range") } let(:organization) { create(:organization) } let(:participatory_process) { create(:participatory_process, :with_steps, organization:) } @@ -25,18 +18,10 @@ module Decidim::ExtraUserFields end context "when there are participants with extended data" do - let(:user_female_young) do - create(:user, :confirmed, organization:, extended_data: { "gender" => "female", "age_range" => "17_to_30" }) - end - let(:user_male_young) do - create(:user, :confirmed, organization:, extended_data: { "gender" => "male", "age_range" => "17_to_30" }) - end - let(:user_female_old) do - create(:user, :confirmed, organization:, extended_data: { "gender" => "female", "age_range" => "61_or_more" }) - end - let(:user_no_data) do - create(:user, :confirmed, organization:, extended_data: {}) - end + let(:user_female_young) { create(:user, :confirmed, organization:, extended_data: { "gender" => "female", "age_range" => "17_to_30" }) } + let(:user_male_young) { create(:user, :confirmed, organization:, extended_data: { "gender" => "male", "age_range" => "17_to_30" }) } + let(:user_female_old) { create(:user, :confirmed, organization:, extended_data: { "gender" => "female", "age_range" => "61_or_more" }) } + let(:user_no_data) { create(:user, :confirmed, organization:, extended_data: {}) } before do create(:proposal, :published, component: proposal_component, users: [user_female_young]) @@ -68,14 +53,7 @@ module Decidim::ExtraUserFields end context "with an invalid metric name" do - subject do - described_class.new( - participatory_space: participatory_process, - metric_name: "invalid", - row_field: "gender", - col_field: "age_range" - ) - end + subject { described_class.new(participatory_space: participatory_process, metric_name: "invalid", row_field: "gender", col_field: "age_range") } it "returns an empty pivot table" do result = subject.call diff --git a/spec/system/admin_views_insights_spec.rb b/spec/system/admin_views_insights_spec.rb index 7bf5673d..a793cf4f 100644 --- a/spec/system/admin_views_insights_spec.rb +++ b/spec/system/admin_views_insights_spec.rb @@ -4,7 +4,6 @@ describe "Admin views insights" do let(:organization) { create(:organization) } - let!(:participatory_process) { create(:participatory_process, organization:) } let(:user) { create(:user, :admin, :confirmed, organization:) } before do @@ -12,31 +11,259 @@ login_as user, scope: :user end - context "when visiting a participatory process admin" do - before do + context "with a participatory process" do + let!(:participatory_process) { create(:participatory_process, organization:) } + + it "shows Insights in the sidebar menu" do visit decidim_admin_participatory_processes.edit_participatory_process_path(participatory_process) + within(".sidebar-menu") do + expect(page).to have_content("Insights") + end end - it "shows the Insights menu item" do - within ".sidebar-menu" do - expect(page).to have_content("Insights") + context "when visiting the insights page" do + before do + visit decidim_admin_participatory_process_insights.root_path( + participatory_process_slug: participatory_process.slug + ) + end + + it "displays the page title" do + expect(page).to have_content("Participatory Space Insights") + end + + it "displays the description" do + expect(page).to have_content("Explore participant activity across profile dimensions") + end + + it "shows the selector dropdowns" do + within(".insights-selectors") do + expect(page).to have_content("Rows (Y axis)") + expect(page).to have_content("Columns (X axis)") + expect(page).to have_content("Metric") + expect(page).to have_content("Gender") + expect(page).to have_content("Age span") + expect(page).to have_content("Participants") + end + end + + it "shows empty state when no participation data exists" do + expect(page).to have_content("No participation data found") + end + end + end + + context "with participation data in a process" do + let!(:participatory_process) { create(:participatory_process, :with_steps, organization:) } + let!(:proposal_component) { create(:proposal_component, :published, participatory_space: participatory_process) } + + let(:user_female_young) { create(:user, :confirmed, organization:, extended_data: { "gender" => "female", "age_range" => "17_to_30" }) } + let(:user_male_young) { create(:user, :confirmed, organization:, extended_data: { "gender" => "male", "age_range" => "17_to_30" }) } + let(:user_no_data) { create(:user, :confirmed, organization:, extended_data: {}) } + + before do + create(:proposal, :published, component: proposal_component, users: [user_female_young]) + create(:proposal, :published, component: proposal_component, users: [user_male_young]) + create(:proposal, :published, component: proposal_component, users: [user_no_data]) + visit decidim_admin_participatory_process_insights.root_path( + participatory_process_slug: participatory_process.slug + ) + end + + it "renders the pivot table headers" do + within("thead") do + expect(page).to have_content("17 to 30") + expect(page).to have_content("Row Total") + end + end + + it "renders row labels in the pivot table" do + within("tbody") do + expect(page).to have_content("Female") + expect(page).to have_content("Male") + expect(page).to have_content("Non specified") + end + end + + it "renders the column totals and grand total" do + within("tfoot") do + expect(page).to have_content("Column Total") + expect(page).to have_content("3") + end + end + + it "shows the legend" do + within(".insights-legend") do + expect(page).to have_content("Fewer") + expect(page).to have_content("More") + end + end + end + + context "when switching axes via query params" do + let!(:participatory_process) { create(:participatory_process, :with_steps, organization:) } + let!(:proposal_component) { create(:proposal_component, :published, participatory_space: participatory_process) } + let(:user_with_data) { create(:user, :confirmed, organization:, extended_data: { "gender" => "female", "age_range" => "17_to_30", "country" => "france" }) } + + before do + create(:proposal, :published, component: proposal_component, users: [user_with_data]) + end + + it "swaps rows and columns when params change" do + visit decidim_admin_participatory_process_insights.root_path( + participatory_process_slug: participatory_process.slug, + rows: "age_range", + cols: "gender" + ) + + within("thead") do + expect(page).to have_content("Female") + end + + within("tbody") do + expect(page).to have_content("17 to 30") + end + end + + it "uses a different field when specified" do + visit decidim_admin_participatory_process_insights.root_path( + participatory_process_slug: participatory_process.slug, + rows: "gender", + cols: "country" + ) + + within("thead") do + expect(page).to have_content("france") + end + + within("tbody") do + expect(page).to have_content("Female") end end end - context "when visiting the insights page" do + context "when switching metrics" do + let!(:participatory_process) { create(:participatory_process, :with_steps, organization:) } + let!(:proposal_component) { create(:proposal_component, :published, participatory_space: participatory_process) } + let(:author) { create(:user, :confirmed, organization:, extended_data: { "gender" => "female", "age_range" => "17_to_30" }) } + let!(:proposal) { create(:proposal, :published, component: proposal_component, users: [author]) } + + before do + create_list(:comment, 3, commentable: proposal, author: author) + end + + it "shows comments metric data when selected" do + visit decidim_admin_participatory_process_insights.root_path( + participatory_process_slug: participatory_process.slug, + metric: "comments" + ) + + within("tfoot") do + expect(page).to have_content("3") + end + end + + it "shows participants metric by default" do + visit decidim_admin_participatory_process_insights.root_path( + participatory_process_slug: participatory_process.slug + ) + + within("tfoot") do + expect(page).to have_content("1") + end + end + end + + context "when cells have heatmap styling" do + let!(:participatory_process) { create(:participatory_process, :with_steps, organization:) } + let!(:proposal_component) { create(:proposal_component, :published, participatory_space: participatory_process) } + let(:user_with_data) { create(:user, :confirmed, organization:, extended_data: { "gender" => "female", "age_range" => "17_to_30" }) } + before do + create(:proposal, :published, component: proposal_component, users: [user_with_data]) visit decidim_admin_participatory_process_insights.root_path( participatory_process_slug: participatory_process.slug ) end - it "displays the page title" do + it "applies inline background-color style to data cells" do + cell = find("td.insights-table__cell", match: :first) + style = cell[:style] || "" + expect(style).to include("background-color") + end + + it "applies inline style to row total cells" do + cell = find("td.insights-table__row-total", match: :first) + style = cell[:style] || "" + expect(style).to include("background-color") + end + + it "applies inline style to column total cells" do + cell = find("td.insights-table__col-total", match: :first) + style = cell[:style] || "" + expect(style).to include("background-color") + end + end + + context "when query params are invalid" do + let!(:participatory_process) { create(:participatory_process, :with_steps, organization:) } + let!(:proposal_component) { create(:proposal_component, :published, participatory_space: participatory_process) } + let(:user_with_data) { create(:user, :confirmed, organization:, extended_data: { "gender" => "female", "age_range" => "17_to_30" }) } + + before do + create(:proposal, :published, component: proposal_component, users: [user_with_data]) + end + + it "falls back to defaults for invalid rows param" do + visit decidim_admin_participatory_process_insights.root_path( + participatory_process_slug: participatory_process.slug, + rows: "nonexistent_field" + ) + + expect(page).to have_content("Participatory Space Insights") + within("tbody") do + expect(page).to have_content("Female") + end + end + + it "falls back to defaults for invalid metric param" do + visit decidim_admin_participatory_process_insights.root_path( + participatory_process_slug: participatory_process.slug, + metric: "fake_metric" + ) + expect(page).to have_content("Participatory Space Insights") + within("tfoot") do + expect(page).to have_content("1") + end end + end - it "displays the description" do - expect(page).to have_content("Explore participant activity across profile dimensions") + context "with an assembly" do + let!(:assembly) { create(:assembly, organization:) } + + it "shows Insights in the sidebar menu" do + visit decidim_admin_assemblies.edit_assembly_path(assembly) + within(".sidebar-menu") do + expect(page).to have_content("Insights") + end + end + + it "loads the insights page with correct layout" do + visit decidim_admin_assembly_insights.root_path(assembly_slug: assembly.slug) + expect(page).to have_content("Participatory Space Insights") + end + end + + context "when user is not admin" do + let!(:participatory_process) { create(:participatory_process, organization:) } + let(:user) { create(:user, :confirmed, organization:) } + + it "cannot access the insights page" do + visit decidim_admin_participatory_process_insights.root_path( + participatory_process_slug: participatory_process.slug + ) + expect(page).to have_no_content("Participatory Space Insights") end end end From 90661ea7b37869ac7d6aaf0b7b5e20fb19c8bb04 Mon Sep 17 00:00:00 2001 From: Anna Topalidi Date: Thu, 26 Feb 2026 11:37:43 +0100 Subject: [PATCH 07/15] refactoring --- .../admin/insights_controller.rb | 18 +-- .../admin/insights_helper.rb | 43 ------ .../decidim/extra_user_fields/pivot_table.rb | 19 --- .../pivot_table_presenter.rb | 82 +++++++++++ .../admin/insights/_pivot_table.html.erb | 24 ++-- .../admin/insights_helper_spec.rb | 73 ---------- .../extra_user_fields/pivot_table_spec.rb | 65 --------- .../pivot_table_presenter_spec.rb | 128 ++++++++++++++++++ 8 files changed, 232 insertions(+), 220 deletions(-) create mode 100644 app/presenters/decidim/extra_user_fields/pivot_table_presenter.rb create mode 100644 spec/presenters/decidim/extra_user_fields/pivot_table_presenter_spec.rb diff --git a/app/controllers/decidim/extra_user_fields/admin/insights_controller.rb b/app/controllers/decidim/extra_user_fields/admin/insights_controller.rb index e14777d1..2bcab252 100644 --- a/app/controllers/decidim/extra_user_fields/admin/insights_controller.rb +++ b/app/controllers/decidim/extra_user_fields/admin/insights_controller.rb @@ -8,7 +8,7 @@ class InsightsController < Decidim::Admin::ApplicationController helper InsightsHelper layout :layout - helper_method :pivot_table, :current_metric, :current_row_field, :current_col_field, + helper_method :pivot_table_presenter, :current_metric, :current_row_field, :current_col_field, :available_metrics, :available_fields before_action :set_breadcrumbs @@ -19,13 +19,15 @@ def show private - def pivot_table - @pivot_table ||= PivotTableBuilder.new( - participatory_space: current_participatory_space, - metric_name: current_metric, - row_field: current_row_field, - col_field: current_col_field - ).call + def pivot_table_presenter + @pivot_table_presenter ||= PivotTablePresenter.new( + PivotTableBuilder.new( + participatory_space: current_participatory_space, + metric_name: current_metric, + row_field: current_row_field, + col_field: current_col_field + ).call + ) end def current_metric diff --git a/app/helpers/decidim/extra_user_fields/admin/insights_helper.rb b/app/helpers/decidim/extra_user_fields/admin/insights_helper.rb index 865c3dab..6cacb6ac 100644 --- a/app/helpers/decidim/extra_user_fields/admin/insights_helper.rb +++ b/app/helpers/decidim/extra_user_fields/admin/insights_helper.rb @@ -41,27 +41,6 @@ def field_value_label(field_name, value) t(key, default: value.humanize) end - # Inline style for a data cell's heatmap coloring. - # Colored cells normalize against max of specified (non-nil) cells. - # Gray cells normalize against the overall max. - def cell_style(value, pivot_table, row, col) - if row.nil? || col.nil? - heatmap_color(value, pivot_table.max_value, gray: true) - else - heatmap_color(value, pivot_table.max_specified_value) - end - end - - # Heatmap style for Row Total cells (normalized among row totals). - def row_total_style(value, pivot_table) - heatmap_color(value, pivot_table.max_row_total) - end - - # Heatmap style for Column Total cells (normalized among col totals). - def col_total_style(value, pivot_table) - heatmap_color(value, pivot_table.max_col_total) - end - private def i18n_key_for_field_value(field_name, value) @@ -74,28 +53,6 @@ def i18n_key_for_field_value(field_name, value) "decidim.admin.extra_user_fields.insights.field_values.#{field_name}.#{value}" end end - - # Compute inline heatmap color for a cell value. - # When gray: true, uses a neutral gray gradient (for non-specified values). - # Otherwise uses a yellow → red gradient. - def heatmap_color(value, max_value, gray: false) - return "" if max_value.zero? || value.zero? - - intensity = value.to_f / max_value - text_color = intensity > 0.65 ? "#fff" : "#1a1a1a" - - bg = if gray - lightness = (95 - (intensity * 35)).round - "hsl(0, 0%, #{lightness}%)" - else - hue = (50 * (1 - intensity)).round - saturation = (90 + (intensity * 10)).round - lightness = (90 - (intensity * 45)).round - "hsl(#{hue}, #{saturation}%, #{lightness}%)" - end - - "background-color: #{bg}; color: #{text_color};" - end end end end diff --git a/app/models/decidim/extra_user_fields/pivot_table.rb b/app/models/decidim/extra_user_fields/pivot_table.rb index bcc276a7..64398007 100644 --- a/app/models/decidim/extra_user_fields/pivot_table.rb +++ b/app/models/decidim/extra_user_fields/pivot_table.rb @@ -48,25 +48,6 @@ def max_value @max_value ||= cells.values.flat_map(&:values).max || 0 end - # Max among cells where both row and col are non-nil. - # Used for heatmap normalization of colored (non-gray) cells. - def max_specified_value - @max_specified_value ||= begin - values = row_values.compact.flat_map do |row| - col_values.compact.map { |col| cell(row, col) } - end - values.max || 0 - end - end - - def max_row_total - @max_row_total ||= row_totals.values.max || 0 - end - - def max_col_total - @max_col_total ||= col_totals.values.max || 0 - end - def empty? grand_total.zero? end diff --git a/app/presenters/decidim/extra_user_fields/pivot_table_presenter.rb b/app/presenters/decidim/extra_user_fields/pivot_table_presenter.rb new file mode 100644 index 00000000..63de387d --- /dev/null +++ b/app/presenters/decidim/extra_user_fields/pivot_table_presenter.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module Decidim + module ExtraUserFields + # Presentation logic for PivotTable heatmap visualization. + # Wraps a PivotTable and provides styling methods for cells, row totals, and column totals. + class PivotTablePresenter + delegate :row_values, :col_values, :cell, :row_total, :col_total, + :grand_total, :empty?, :max_value, to: :pivot_table + + def initialize(pivot_table) + @pivot_table = pivot_table + end + + # Inline style for a data cell's heatmap coloring. + # Colored cells normalize against max of specified (non-nil) cells. + # Gray cells normalize against the overall max. + def cell_style(value, row, col) + if row.nil? || col.nil? + heatmap_color(value, max_value, gray: true) + else + heatmap_color(value, max_specified_value) + end + end + + # Heatmap style for Row Total cells (normalized among row totals). + def row_total_style(value) + heatmap_color(value, max_row_total) + end + + # Heatmap style for Column Total cells (normalized among col totals). + def col_total_style(value) + heatmap_color(value, max_col_total) + end + + private + + attr_reader :pivot_table + + # Max among cells where both row and col are non-nil. + # Used for heatmap normalization of colored (non-gray) cells. + def max_specified_value + @max_specified_value ||= begin + values = row_values.compact.flat_map do |row| + col_values.compact.map { |col| cell(row, col) } + end + values.max || 0 + end + end + + def max_row_total + @max_row_total ||= row_values.map { |row| row_total(row) }.max || 0 + end + + def max_col_total + @max_col_total ||= col_values.map { |col| col_total(col) }.max || 0 + end + + # Compute inline heatmap color for a cell value. + # When gray: true, uses a neutral gray gradient (for non-specified values). + # Otherwise uses a yellow -> red gradient. + def heatmap_color(value, max_value, gray: false) + return "" if max_value.zero? || value.zero? + + intensity = value.to_f / max_value + text_color = intensity > 0.65 ? "#fff" : "#1a1a1a" + + bg = if gray + lightness = (95 - (intensity * 35)).round + "hsl(0, 0%, #{lightness}%)" + else + hue = (50 * (1 - intensity)).round + saturation = (90 + (intensity * 10)).round + lightness = (90 - (intensity * 45)).round + "hsl(#{hue}, #{saturation}%, #{lightness}%)" + end + + "background-color: #{bg}; color: #{text_color};" + end + end + end +end diff --git a/app/views/decidim/extra_user_fields/admin/insights/_pivot_table.html.erb b/app/views/decidim/extra_user_fields/admin/insights/_pivot_table.html.erb index cd0844f6..5d31f62e 100644 --- a/app/views/decidim/extra_user_fields/admin/insights/_pivot_table.html.erb +++ b/app/views/decidim/extra_user_fields/admin/insights/_pivot_table.html.erb @@ -1,4 +1,4 @@ -<% if pivot_table.empty? %> +<% if pivot_table_presenter.empty? %>

<%= t("decidim.admin.extra_user_fields.insights.no_data") %>

@@ -13,7 +13,7 @@ <%= field_label(current_row_field) %> \ <%= field_label(current_col_field) %> - <% pivot_table.col_values.each do |col| %> + <% pivot_table_presenter.col_values.each do |col| %> <%= field_value_label(current_col_field, col) %> @@ -24,19 +24,19 @@ - <% pivot_table.row_values.each do |row| %> + <% pivot_table_presenter.row_values.each do |row| %> <%= field_value_label(current_row_field, row) %> - <% pivot_table.col_values.each do |col| %> - <% value = pivot_table.cell(row, col) %> - + <% pivot_table_presenter.col_values.each do |col| %> + <% value = pivot_table_presenter.cell(row, col) %> + <%= number_with_delimiter(value) %> <% end %> - - <%= number_with_delimiter(pivot_table.row_total(row)) %> + + <%= number_with_delimiter(pivot_table_presenter.row_total(row)) %> <% end %> @@ -46,13 +46,13 @@ <%= t("decidim.admin.extra_user_fields.insights.column_total") %> - <% pivot_table.col_values.each do |col| %> - - <%= number_with_delimiter(pivot_table.col_total(col)) %> + <% pivot_table_presenter.col_values.each do |col| %> + + <%= number_with_delimiter(pivot_table_presenter.col_total(col)) %> <% end %> - <%= number_with_delimiter(pivot_table.grand_total) %> + <%= number_with_delimiter(pivot_table_presenter.grand_total) %> diff --git a/spec/helpers/decidim/extra_user_fields/admin/insights_helper_spec.rb b/spec/helpers/decidim/extra_user_fields/admin/insights_helper_spec.rb index ca3abb70..22045b6b 100644 --- a/spec/helpers/decidim/extra_user_fields/admin/insights_helper_spec.rb +++ b/spec/helpers/decidim/extra_user_fields/admin/insights_helper_spec.rb @@ -55,79 +55,6 @@ module Decidim::ExtraUserFields::Admin end end - describe "#cell_style" do - let(:pivot_table) do - Decidim::ExtraUserFields::PivotTable.new( - row_values: ["female", "male", nil], - col_values: ["young", nil], - cells: { - "female" => { "young" => 10 }, - "male" => { "young" => 5 }, - nil => { nil => 3 } - } - ) - end - - it "returns empty string when value is zero" do - expect(helper.cell_style(0, pivot_table, "female", "young")).to eq("") - end - - it "returns colored gradient for specified cells" do - result = helper.cell_style(10, pivot_table, "female", "young") - expect(result).to include("background-color:") - # Max intensity produces hue=0 (red end of yellow-to-red gradient) - expect(result).to include("hsl(0, 100%, 45%)") - end - - it "returns gray gradient when row is nil" do - result = helper.cell_style(3, pivot_table, nil, nil) - expect(result).to include("hsl(0, 0%,") - end - - it "returns gray gradient when col is nil" do - result = helper.cell_style(5, pivot_table, "female", nil) - expect(result).to include("hsl(0, 0%,") - end - end - - describe "#row_total_style" do - let(:pivot_table) do - Decidim::ExtraUserFields::PivotTable.new( - row_values: %w(a b), - col_values: %w(x y), - cells: { "a" => { "x" => 10, "y" => 5 }, "b" => { "x" => 3, "y" => 2 } } - ) - end - - it "returns empty string when value is zero" do - expect(helper.row_total_style(0, pivot_table)).to eq("") - end - - it "returns hsl gradient for non-zero values" do - result = helper.row_total_style(15, pivot_table) - expect(result).to include("background-color: hsl(") - end - end - - describe "#col_total_style" do - let(:pivot_table) do - Decidim::ExtraUserFields::PivotTable.new( - row_values: %w(a b), - col_values: %w(x y), - cells: { "a" => { "x" => 10, "y" => 5 }, "b" => { "x" => 3, "y" => 2 } } - ) - end - - it "returns empty string when value is zero" do - expect(helper.col_total_style(0, pivot_table)).to eq("") - end - - it "returns hsl gradient for non-zero values" do - result = helper.col_total_style(13, pivot_table) - expect(result).to include("background-color: hsl(") - end - end - describe "#insight_selector_field" do it "renders a selector with label and select tag" do result = helper.insight_selector_field(:rows, %w(gender age_range), "gender", &:humanize) diff --git a/spec/models/decidim/extra_user_fields/pivot_table_spec.rb b/spec/models/decidim/extra_user_fields/pivot_table_spec.rb index c12bd249..f3baa7ca 100644 --- a/spec/models/decidim/extra_user_fields/pivot_table_spec.rb +++ b/spec/models/decidim/extra_user_fields/pivot_table_spec.rb @@ -52,71 +52,6 @@ module Decidim::ExtraUserFields end end - describe "#max_specified_value" do - context "when all rows and cols are non-nil" do - it "returns the highest cell value" do - expect(pivot_table.max_specified_value).to eq(20) - end - end - - context "when some rows or cols are nil" do - let(:row_values) { ["18_to_25", nil] } - let(:col_values) { ["female", nil] } - let(:cells) do - { - "18_to_25" => { "female" => 5, nil => 99 }, - nil => { "female" => 88, nil => 77 } - } - end - - it "excludes cells where row or col is nil" do - expect(pivot_table.max_specified_value).to eq(5) - end - end - - context "when there are no non-nil combinations" do - let(:row_values) { [nil] } - let(:col_values) { [nil] } - let(:cells) { { nil => { nil => 10 } } } - - it "returns 0" do - expect(pivot_table.max_specified_value).to eq(0) - end - end - end - - describe "#max_row_total" do - it "returns the highest row total" do - expect(pivot_table.max_row_total).to eq(35) - end - - context "when there are no cells" do - let(:row_values) { [] } - let(:col_values) { [] } - let(:cells) { {} } - - it "returns 0" do - expect(pivot_table.max_row_total).to eq(0) - end - end - end - - describe "#max_col_total" do - it "returns the highest column total" do - expect(pivot_table.max_col_total).to eq(30) - end - - context "when there are no cells" do - let(:row_values) { [] } - let(:col_values) { [] } - let(:cells) { {} } - - it "returns 0" do - expect(pivot_table.max_col_total).to eq(0) - end - end - end - describe "#empty?" do it "returns false when there is data" do expect(pivot_table.empty?).to be(false) diff --git a/spec/presenters/decidim/extra_user_fields/pivot_table_presenter_spec.rb b/spec/presenters/decidim/extra_user_fields/pivot_table_presenter_spec.rb new file mode 100644 index 00000000..57135132 --- /dev/null +++ b/spec/presenters/decidim/extra_user_fields/pivot_table_presenter_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::ExtraUserFields + describe PivotTablePresenter do + subject(:presenter) { described_class.new(pivot_table) } + + let(:pivot_table) { PivotTable.new(row_values: row_values, col_values: col_values, cells: cells) } + let(:row_values) { %w(female male) } + let(:col_values) { %w(young old) } + let(:cells) { { "female" => { "young" => 10, "old" => 5 }, "male" => { "young" => 3, "old" => 2 } } } + + describe "delegation" do + it "delegates data methods to pivot_table" do + expect(presenter.row_values).to eq(pivot_table.row_values) + expect(presenter.col_values).to eq(pivot_table.col_values) + expect(presenter.cell("female", "young")).to eq(10) + expect(presenter.row_total("female")).to eq(15) + expect(presenter.col_total("young")).to eq(13) + expect(presenter.grand_total).to eq(20) + expect(presenter.empty?).to be(false) + end + end + + describe "#cell_style" do + it "returns empty string when value is zero" do + expect(presenter.cell_style(0, "female", "young")).to eq("") + end + + it "returns colored gradient for specified cells" do + result = presenter.cell_style(10, "female", "young") + expect(result).to include("background-color:") + expect(result).to include("hsl(0, 100%, 45%)") + end + + context "when row or col is nil" do + let(:row_values) { ["female", nil] } + let(:col_values) { ["young", nil] } + let(:cells) { { "female" => { "young" => 10, nil => 5 }, nil => { "young" => 3, nil => 7 } } } + + it "returns gray gradient when row is nil" do + result = presenter.cell_style(7, nil, nil) + expect(result).to include("hsl(0, 0%,") + end + + it "returns gray gradient when col is nil" do + result = presenter.cell_style(5, "female", nil) + expect(result).to include("hsl(0, 0%,") + end + + it "uses max_specified_value for colored cells, not overall max" do + # max_specified_value = 10 (only female/young), not 10 (overall max) + result = presenter.cell_style(10, "female", "young") + # At full intensity: hue=0, saturation=100%, lightness=45% + expect(result).to include("hsl(0, 100%, 45%)") + end + end + end + + describe "#row_total_style" do + it "returns empty string when value is zero" do + expect(presenter.row_total_style(0)).to eq("") + end + + it "returns hsl gradient for non-zero values" do + result = presenter.row_total_style(15) + expect(result).to include("background-color: hsl(") + end + end + + describe "#col_total_style" do + it "returns empty string when value is zero" do + expect(presenter.col_total_style(0)).to eq("") + end + + it "returns hsl gradient for non-zero values" do + result = presenter.col_total_style(13) + expect(result).to include("background-color: hsl(") + end + end + + describe "max value calculations" do + context "when some rows or cols are nil" do + let(:row_values) { ["female", nil] } + let(:col_values) { ["young", nil] } + let(:cells) { { "female" => { "young" => 5, nil => 99 }, nil => { "young" => 88, nil => 77 } } } + + it "normalizes colored cells against specified-only max" do + # max_specified_value = 5, so value 5 is full intensity + result = presenter.cell_style(5, "female", "young") + expect(result).to include("hsl(0, 100%, 45%)") + end + + it "normalizes gray cells against overall max" do + # max_value = 99, so value 99 is full intensity + result = presenter.cell_style(99, "female", nil) + expect(result).to include("hsl(0, 0%,") + end + end + + context "when there are no non-nil combinations" do + let(:row_values) { [nil] } + let(:col_values) { [nil] } + let(:cells) { { nil => { nil => 10 } } } + + it "returns gray style for the only cell" do + result = presenter.cell_style(10, nil, nil) + expect(result).to include("hsl(0, 0%,") + end + end + + context "when there are no cells" do + let(:row_values) { [] } + let(:col_values) { [] } + let(:cells) { {} } + + it "returns empty string for row total" do + expect(presenter.row_total_style(0)).to eq("") + end + + it "returns empty string for col total" do + expect(presenter.col_total_style(0)).to eq("") + end + end + end + end +end From 7b6223f7615c3aa281b3f1645674c8edfc8d12e4 Mon Sep 17 00:00:00 2001 From: Anna Topalidi Date: Thu, 26 Feb 2026 11:57:42 +0100 Subject: [PATCH 08/15] fic sorting --- .../extra_user_fields/pivot_table_builder.rb | 35 ++++++++++++++----- .../pivot_table_builder_spec.rb | 35 +++++++++++++++++++ 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/app/services/decidim/extra_user_fields/pivot_table_builder.rb b/app/services/decidim/extra_user_fields/pivot_table_builder.rb index b0dc7545..10135089 100644 --- a/app/services/decidim/extra_user_fields/pivot_table_builder.rb +++ b/app/services/decidim/extra_user_fields/pivot_table_builder.rb @@ -2,10 +2,8 @@ module Decidim module ExtraUserFields - # Builds a PivotTable by: - # 1. Running a metric query to get { user_id => count } - # 2. Loading those users and reading their extended_data for row/col fields - # 3. Aggregating counts into a cross-tabulation matrix + # Produces a PivotTable that cross-tabulates a participation metric + # against two user-profile dimensions within a single participatory space. class PivotTableBuilder # @param participatory_space [Decidim::ParticipatoryProcess, Decidim::Assembly] # @param metric_name [String] key from InsightMetrics::REGISTRY @@ -26,8 +24,8 @@ def call users = load_users(metric_data.keys) cells = build_cells(metric_data, users) - row_vals = cells.keys.sort_by { |v| sort_key(v) } - col_vals = cells.values.flat_map(&:keys).uniq.sort_by { |v| sort_key(v) } + row_vals = cells.keys.sort_by { |v| sort_key(v, row_field) } + col_vals = cells.values.flat_map(&:keys).uniq.sort_by { |v| sort_key(v, col_field) } PivotTable.new(row_values: row_vals, col_values: col_vals, cells: cells) end @@ -72,9 +70,28 @@ def empty_pivot_table PivotTable.new(row_values: [], col_values: [], cells: {}) end - # Sort values alphabetically, nil (non-specified) goes last. - def sort_key(value) - value.nil? ? [1, ""] : [0, value.to_s] + # Sort values using domain order when available (e.g. age_ranges, genders), + # falling back to alphabetical. Nil (non-specified) always goes last. + def sort_key(value, field) + return [1, 0] if value.nil? + + ordered = ordered_values_for(field) + if ordered + index = ordered.index(value.to_s) + index ? [0, index] : [0, ordered.size, value.to_s] + else + [0, 0, value.to_s] + end + end + + def ordered_values_for(field) + @ordered_values ||= {} + return @ordered_values[field] if @ordered_values.key?(field) + + @ordered_values[field] = case field.to_s + when "gender" then Decidim::ExtraUserFields.genders + when "age_range" then Decidim::ExtraUserFields.age_ranges + end end end end diff --git a/spec/services/decidim/extra_user_fields/pivot_table_builder_spec.rb b/spec/services/decidim/extra_user_fields/pivot_table_builder_spec.rb index 04e52144..846edac6 100644 --- a/spec/services/decidim/extra_user_fields/pivot_table_builder_spec.rb +++ b/spec/services/decidim/extra_user_fields/pivot_table_builder_spec.rb @@ -36,6 +36,22 @@ module Decidim::ExtraUserFields expect(result.col_values).to include("17_to_30", "61_or_more", nil) end + it "sorts gender rows by config order, nil last" do + result = subject.call + # config.genders: female, male, other, prefer_not_to_say + specified = result.row_values.compact + expect(specified).to eq(%w(female male)) + expect(result.row_values.last).to be_nil + end + + it "sorts age_range columns by config order, nil last" do + result = subject.call + # config.age_ranges: up_to_16, 17_to_30, 31_to_60, 61_or_more, prefer_not_to_say + specified = result.col_values.compact + expect(specified).to eq(%w(17_to_30 61_or_more)) + expect(result.col_values.last).to be_nil + end + it "fills cells with correct counts" do result = subject.call expect(result.cell("female", "17_to_30")).to eq(1) @@ -52,6 +68,25 @@ module Decidim::ExtraUserFields end end + context "when age_range values would sort wrong alphabetically" do + let(:user_young) { create(:user, :confirmed, organization:, extended_data: { "gender" => "female", "age_range" => "17_to_30" }) } + let(:user_old) { create(:user, :confirmed, organization:, extended_data: { "gender" => "female", "age_range" => "61_or_more" }) } + let(:user_teen) { create(:user, :confirmed, organization:, extended_data: { "gender" => "female", "age_range" => "up_to_16" }) } + + before do + create(:proposal, :published, component: proposal_component, users: [user_young]) + create(:proposal, :published, component: proposal_component, users: [user_old]) + create(:proposal, :published, component: proposal_component, users: [user_teen]) + end + + it "sorts by domain order, not alphabetically" do + result = subject.call + # Alphabetical would give: 17_to_30, 61_or_more, up_to_16 + # Domain order should give: up_to_16, 17_to_30, 61_or_more + expect(result.col_values).to eq(%w(up_to_16 17_to_30 61_or_more)) + end + end + context "with an invalid metric name" do subject { described_class.new(participatory_space: participatory_process, metric_name: "invalid", row_field: "gender", col_field: "age_range") } From 3c9baca580f86a3d5260b585dd14963d1eaa16a7 Mon Sep 17 00:00:00 2001 From: Anna Topalidi Date: Thu, 26 Feb 2026 12:15:13 +0100 Subject: [PATCH 09/15] Min-max among cells --- .../pivot_table_presenter.rb | 70 ++++++++++++------- .../admin/insights/_legend.html.erb | 10 +-- .../pivot_table_presenter_spec.rb | 48 ++++++------- 3 files changed, 72 insertions(+), 56 deletions(-) diff --git a/app/presenters/decidim/extra_user_fields/pivot_table_presenter.rb b/app/presenters/decidim/extra_user_fields/pivot_table_presenter.rb index 63de387d..38d38eaf 100644 --- a/app/presenters/decidim/extra_user_fields/pivot_table_presenter.rb +++ b/app/presenters/decidim/extra_user_fields/pivot_table_presenter.rb @@ -4,6 +4,9 @@ module Decidim module ExtraUserFields # Presentation logic for PivotTable heatmap visualization. # Wraps a PivotTable and provides styling methods for cells, row totals, and column totals. + # Uses min-max normalization: color intensity reflects where a value falls + # between the minimum and maximum in its group. When all values are equal, + # cells appear at baseline intensity (no hotspots). class PivotTablePresenter delegate :row_values, :col_values, :cell, :row_total, :col_total, :grand_total, :empty?, :max_value, to: :pivot_table @@ -13,65 +16,80 @@ def initialize(pivot_table) end # Inline style for a data cell's heatmap coloring. - # Colored cells normalize against max of specified (non-nil) cells. - # Gray cells normalize against the overall max. + # Colored cells use min-max among specified (non-nil) cells. + # Gray cells use min-max among all cells. def cell_style(value, row, col) if row.nil? || col.nil? - heatmap_color(value, max_value, gray: true) + min, max = all_cell_range + heatmap_color(value, min, max, gray: true) else - heatmap_color(value, max_specified_value) + min, max = specified_cell_range + heatmap_color(value, min, max) end end - # Heatmap style for Row Total cells (normalized among row totals). + # Heatmap style for Row Total cells. def row_total_style(value) - heatmap_color(value, max_row_total) + heatmap_color(value, *row_total_range) end - # Heatmap style for Column Total cells (normalized among col totals). + # Heatmap style for Column Total cells. def col_total_style(value) - heatmap_color(value, max_col_total) + heatmap_color(value, *col_total_range) end private attr_reader :pivot_table - # Max among cells where both row and col are non-nil. - # Used for heatmap normalization of colored (non-gray) cells. - def max_specified_value - @max_specified_value ||= begin + # Min-max among cells where both row and col are non-nil. + def specified_cell_range + @specified_cell_range ||= begin values = row_values.compact.flat_map do |row| col_values.compact.map { |col| cell(row, col) } end - values.max || 0 + min_max(values) end end - def max_row_total - @max_row_total ||= row_values.map { |row| row_total(row) }.max || 0 + # Min-max among all cells. + def all_cell_range + @all_cell_range ||= min_max(pivot_table.cells.values.flat_map(&:values)) end - def max_col_total - @max_col_total ||= col_values.map { |col| col_total(col) }.max || 0 + def row_total_range + @row_total_range ||= min_max(row_values.map { |row| row_total(row) }) end - # Compute inline heatmap color for a cell value. - # When gray: true, uses a neutral gray gradient (for non-specified values). - # Otherwise uses a yellow -> red gradient. - def heatmap_color(value, max_value, gray: false) - return "" if max_value.zero? || value.zero? + def col_total_range + @col_total_range ||= min_max(col_values.map { |col| col_total(col) }) + end + + def min_max(values) + non_zero = values.select(&:positive?) + return [0, 0] if non_zero.empty? + + [non_zero.min, non_zero.max] + end + + # Compute inline heatmap color using min-max normalization. + # intensity = (value - min) / (max - min), so the lowest non-zero value + # gets baseline color and the highest gets full color. + # When min == max (all equal), intensity is 0 (baseline). + def heatmap_color(value, min, max, gray: false) + return "" if value.zero? || max.zero? - intensity = value.to_f / max_value - text_color = intensity > 0.65 ? "#fff" : "#1a1a1a" + range = max - min + intensity = range.zero? ? 0.0 : (value - min).to_f / range + text_color = intensity > 0.75 ? "#fff" : "#1a1a1a" bg = if gray lightness = (95 - (intensity * 35)).round "hsl(0, 0%, #{lightness}%)" else hue = (50 * (1 - intensity)).round - saturation = (90 + (intensity * 10)).round - lightness = (90 - (intensity * 45)).round + saturation = (90 - (intensity * 12)).round + lightness = (86 - (intensity * 28)).round "hsl(#{hue}, #{saturation}%, #{lightness}%)" end diff --git a/app/views/decidim/extra_user_fields/admin/insights/_legend.html.erb b/app/views/decidim/extra_user_fields/admin/insights/_legend.html.erb index 3489905a..0aa66212 100644 --- a/app/views/decidim/extra_user_fields/admin/insights/_legend.html.erb +++ b/app/views/decidim/extra_user_fields/admin/insights/_legend.html.erb @@ -1,11 +1,11 @@
<%= t("decidim.admin.extra_user_fields.insights.legend.fewer") %>
-
-
-
-
-
+
+
+
+
+
<%= t("decidim.admin.extra_user_fields.insights.legend.more") %> diff --git a/spec/presenters/decidim/extra_user_fields/pivot_table_presenter_spec.rb b/spec/presenters/decidim/extra_user_fields/pivot_table_presenter_spec.rb index 57135132..7ef96323 100644 --- a/spec/presenters/decidim/extra_user_fields/pivot_table_presenter_spec.rb +++ b/spec/presenters/decidim/extra_user_fields/pivot_table_presenter_spec.rb @@ -28,10 +28,27 @@ module Decidim::ExtraUserFields expect(presenter.cell_style(0, "female", "young")).to eq("") end - it "returns colored gradient for specified cells" do + it "returns colored gradient for the max value cell" do + # min=2, max=10 → value 10 is full intensity result = presenter.cell_style(10, "female", "young") expect(result).to include("background-color:") - expect(result).to include("hsl(0, 100%, 45%)") + expect(result).to include("hsl(0, 78%, 58%)") + end + + it "returns baseline color for the min value cell" do + # min=2, max=10 → value 2 has intensity 0 (baseline yellow) + result = presenter.cell_style(2, "male", "old") + expect(result).to include("hsl(50, 90%, 86%)") + end + + context "when all specified cells have the same value" do + let(:cells) { { "female" => { "young" => 5, "old" => 5 }, "male" => { "young" => 5, "old" => 5 } } } + + it "shows baseline color for all cells (no hotspots)" do + result = presenter.cell_style(5, "female", "young") + # min=max=5, intensity=0 → baseline yellow + expect(result).to include("hsl(50, 90%, 86%)") + end end context "when row or col is nil" do @@ -49,11 +66,10 @@ module Decidim::ExtraUserFields expect(result).to include("hsl(0, 0%,") end - it "uses max_specified_value for colored cells, not overall max" do - # max_specified_value = 10 (only female/young), not 10 (overall max) + it "normalizes colored cells only against specified cells" do + # Only specified cell is female/young=10 → min=max=10, intensity=0 result = presenter.cell_style(10, "female", "young") - # At full intensity: hue=0, saturation=100%, lightness=45% - expect(result).to include("hsl(0, 100%, 45%)") + expect(result).to include("hsl(50, 90%, 86%)") end end end @@ -80,25 +96,7 @@ module Decidim::ExtraUserFields end end - describe "max value calculations" do - context "when some rows or cols are nil" do - let(:row_values) { ["female", nil] } - let(:col_values) { ["young", nil] } - let(:cells) { { "female" => { "young" => 5, nil => 99 }, nil => { "young" => 88, nil => 77 } } } - - it "normalizes colored cells against specified-only max" do - # max_specified_value = 5, so value 5 is full intensity - result = presenter.cell_style(5, "female", "young") - expect(result).to include("hsl(0, 100%, 45%)") - end - - it "normalizes gray cells against overall max" do - # max_value = 99, so value 99 is full intensity - result = presenter.cell_style(99, "female", nil) - expect(result).to include("hsl(0, 0%,") - end - end - + describe "min-max normalization" do context "when there are no non-nil combinations" do let(:row_values) { [nil] } let(:col_values) { [nil] } From da55af0db4c3cef6b61227c6e710b11a2e9e51d1 Mon Sep 17 00:00:00 2001 From: Anna Topalidi Date: Thu, 26 Feb 2026 13:19:10 +0100 Subject: [PATCH 10/15] change styles --- .../admin/insights_helper.rb | 4 +- .../decidim/extra_user_fields/insights.scss | 115 ++++++++---------- .../pivot_table_presenter.rb | 34 ++++-- .../extra_user_fields/pivot_table_builder.rb | 2 +- .../admin/insights/_legend.html.erb | 21 +++- .../admin/insights/_pivot_table.html.erb | 106 ++++++++-------- .../admin/insights/show.html.erb | 1 + config/locales/en.yml | 3 + config/locales/fr.yml | 3 + .../admin/insights_helper_spec.rb | 2 + .../pivot_table_presenter_spec.rb | 38 ++++-- 11 files changed, 184 insertions(+), 145 deletions(-) diff --git a/app/helpers/decidim/extra_user_fields/admin/insights_helper.rb b/app/helpers/decidim/extra_user_fields/admin/insights_helper.rb index 6cacb6ac..296f1ece 100644 --- a/app/helpers/decidim/extra_user_fields/admin/insights_helper.rb +++ b/app/helpers/decidim/extra_user_fields/admin/insights_helper.rb @@ -7,10 +7,10 @@ module InsightsHelper # Render a compact selector: bordered frame with "Label: [value ▾]". # Yields each option to the block for label generation. def insight_selector_field(param_name, options, selected_value, &block) - label_key = "decidim.admin.extra_user_fields.insights.selectors.#{param_name}" + label_text = t("decidim.admin.extra_user_fields.insights.selectors.#{param_name}") content_tag(:div, class: "insights-selectors__field") do - content_tag(:span, t(label_key), class: "insights-selectors__label") + + label_tag(param_name, label_text, class: "insights-selectors__label") + select_tag( param_name, options_for_select(options.map { |opt| [block.call(opt), opt] }, selected_value), diff --git a/app/packs/stylesheets/decidim/extra_user_fields/insights.scss b/app/packs/stylesheets/decidim/extra_user_fields/insights.scss index 4576f9a5..4bda268f 100644 --- a/app/packs/stylesheets/decidim/extra_user_fields/insights.scss +++ b/app/packs/stylesheets/decidim/extra_user_fields/insights.scss @@ -2,27 +2,14 @@ // Selectors — compact inline controls: "Label: [value ▾]" .insights-selectors { - display: flex; - gap: 1.25rem; - flex-wrap: wrap; - align-items: center; - justify-content: center; + @apply flex gap-5 flex-wrap items-center justify-center; &__field { - display: flex; - align-items: center; - border: 1px solid #d1d5db; - border-radius: 6px; - background: #fff; - overflow: hidden; + @apply flex items-center border border-gray-6 rounded-md bg-white overflow-hidden; } &__label { - padding: 0.5rem 0 0.5rem 0.75rem; - font-weight: 600; - font-size: 0.875rem; - color: #374151; - white-space: nowrap; + @apply py-2 pl-3 font-semibold text-sm text-gray-2 whitespace-nowrap; &::after { content: ":"; @@ -30,126 +17,124 @@ } &__select { - border: none; - background: transparent; - padding: 0.5rem 0.75rem 0.5rem 0.375rem; - font-size: 0.875rem; - color: #6b7280; - cursor: pointer; - outline: none; + @apply py-2 pr-3 bg-transparent text-sm text-gray-2 cursor-pointer outline-none border-none; + padding-left: 0.375rem; appearance: auto; &:focus { - color: #111827; + @apply text-gray-4; } } } -// Pivot table -.insights-pivot-table { - overflow-x: auto; +// Pivot table scroll: table grows by content, table-scroll handles overflow. +// min-width:0 on .layout-content is set via

<%= t("decidim.admin.extra_user_fields.insights.title") %> diff --git a/config/locales/en.yml b/config/locales/en.yml index d60b2d68..0b17d76d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -37,6 +37,8 @@ en: postal_code: Postal code legend: fewer: Fewer + higher_total: Higher total + lower_total: Lower total more: More non_specified: Non specified menu_title: Insights @@ -48,6 +50,7 @@ en: proposals_supported: Proposals supported no_data: No participation data found for this space with the selected criteria. non_specified: Non specified + pivot_table_label: Participation data table row_total: Row Total selectors: cols: Columns (X axis) diff --git a/config/locales/fr.yml b/config/locales/fr.yml index c871e882..151c5d2f 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -39,6 +39,8 @@ fr: postal_code: Code postal legend: fewer: Moins + higher_total: Total élevé + lower_total: Total faible more: Plus non_specified: Non spécifié menu_title: Aperçus @@ -51,6 +53,7 @@ fr: no_data: Aucune donnée de participation trouvée pour cet espace avec les critères sélectionnés. non_specified: Non spécifié + pivot_table_label: Tableau des données de participation row_total: Total ligne selectors: cols: Colonnes (axe X) diff --git a/spec/helpers/decidim/extra_user_fields/admin/insights_helper_spec.rb b/spec/helpers/decidim/extra_user_fields/admin/insights_helper_spec.rb index 22045b6b..f3df7483 100644 --- a/spec/helpers/decidim/extra_user_fields/admin/insights_helper_spec.rb +++ b/spec/helpers/decidim/extra_user_fields/admin/insights_helper_spec.rb @@ -60,6 +60,8 @@ module Decidim::ExtraUserFields::Admin result = helper.insight_selector_field(:rows, %w(gender age_range), "gender", &:humanize) expect(result).to include('class="insights-selectors__field"') + expect(result).to include(" value 10 is full intensity result = presenter.cell_style(10, "female", "young") expect(result).to include("background-color:") - expect(result).to include("hsl(0, 78%, 58%)") + expect(result).to include("hsl(0, 78%, 50%)") end it "returns baseline color for the min value cell" do - # min=2, max=10 → value 2 has intensity 0 (baseline yellow) + # min=2, max=10 -> value 2 has intensity 0 (baseline yellow) result = presenter.cell_style(2, "male", "old") expect(result).to include("hsl(50, 90%, 86%)") end @@ -46,7 +46,7 @@ module Decidim::ExtraUserFields it "shows baseline color for all cells (no hotspots)" do result = presenter.cell_style(5, "female", "young") - # min=max=5, intensity=0 → baseline yellow + # min=max=5, intensity=0 -> baseline yellow expect(result).to include("hsl(50, 90%, 86%)") end end @@ -67,7 +67,7 @@ module Decidim::ExtraUserFields end it "normalizes colored cells only against specified cells" do - # Only specified cell is female/young=10 → min=max=10, intensity=0 + # Only specified cell is female/young=10 -> min=max=10, intensity=0 result = presenter.cell_style(10, "female", "young") expect(result).to include("hsl(50, 90%, 86%)") end @@ -79,9 +79,17 @@ module Decidim::ExtraUserFields expect(presenter.row_total_style(0)).to eq("") end - it "returns hsl gradient for non-zero values" do + it "returns blue gradient for the max row total" do + # female total=15 (max), intensity=1.0 result = presenter.row_total_style(15) - expect(result).to include("background-color: hsl(") + expect(result).to include("background-color:") + expect(result).to include("hsl(215,") + end + + it "returns lighter blue for smaller totals" do + # male total=5, max=15, intensity=0.33 + result = presenter.row_total_style(5) + expect(result).to include("hsl(215,") end end @@ -90,9 +98,17 @@ module Decidim::ExtraUserFields expect(presenter.col_total_style(0)).to eq("") end - it "returns hsl gradient for non-zero values" do + it "returns blue gradient for the max column total" do + # young total=13 (max), intensity=1.0 result = presenter.col_total_style(13) - expect(result).to include("background-color: hsl(") + expect(result).to include("background-color:") + expect(result).to include("hsl(215,") + end + + it "returns lighter blue for smaller totals" do + # old total=7, max=13, intensity=0.54 + result = presenter.col_total_style(7) + expect(result).to include("hsl(215,") end end @@ -113,11 +129,11 @@ module Decidim::ExtraUserFields let(:col_values) { [] } let(:cells) { {} } - it "returns empty string for row total" do + it "returns empty string for row total style" do expect(presenter.row_total_style(0)).to eq("") end - it "returns empty string for col total" do + it "returns empty string for col total style" do expect(presenter.col_total_style(0)).to eq("") end end From 5dd94494f66033a61ec197d62508a36d1cc2ae7f Mon Sep 17 00:00:00 2001 From: Anna Topalidi Date: Thu, 26 Feb 2026 14:48:40 +0100 Subject: [PATCH 11/15] styles --- .../decidim/extra_user_fields/insights.scss | 125 ++++++++++++------ .../admin/insights/_legend.html.erb | 20 ++- .../admin/insights/_pivot_table.html.erb | 19 +-- .../admin/insights/show.html.erb | 25 ++-- 4 files changed, 119 insertions(+), 70 deletions(-) diff --git a/app/packs/stylesheets/decidim/extra_user_fields/insights.scss b/app/packs/stylesheets/decidim/extra_user_fields/insights.scss index 4bda268f..99a8d0e9 100644 --- a/app/packs/stylesheets/decidim/extra_user_fields/insights.scss +++ b/app/packs/stylesheets/decidim/extra_user_fields/insights.scss @@ -25,11 +25,18 @@ @apply text-gray-4; } } + +} + +// Required for nested flex containers in admin layout: +// without this, wide tables force page-level horizontal scroll. +.layout-content:has(.insights-page), +.sidebar-menu__container:has(.insights-page), +.item_show__wrapper:has(.insights-page) { + min-width: 0; } // Pivot table scroll: table grows by content, table-scroll handles overflow. -// min-width:0 on .layout-content is set via -
-

- <%= t("decidim.admin.extra_user_fields.insights.title") %> -

-
+
+
+

+ <%= t("decidim.admin.extra_user_fields.insights.title") %> +

+
-
-

<%= t("decidim.admin.extra_user_fields.insights.description") %>

-
+
+

<%= t("decidim.admin.extra_user_fields.insights.description") %>

+
-<%= render partial: "decidim/extra_user_fields/admin/insights/selectors" %> + <%= render partial: "decidim/extra_user_fields/admin/insights/selectors" %> -
- <%= render partial: "decidim/extra_user_fields/admin/insights/pivot_table" %> +
+ <%= render partial: "decidim/extra_user_fields/admin/insights/pivot_table" %> +
From 4721ef34726bd7047b55f8806e995f7e194dc8f1 Mon Sep 17 00:00:00 2001 From: Anna Topalidi Date: Thu, 26 Feb 2026 15:18:01 +0100 Subject: [PATCH 12/15] fix country names --- .../admin/insights_helper.rb | 8 +++++ .../decidim/extra_user_fields/insights.scss | 34 +++++++++++++------ lib/decidim/extra_user_fields.rb | 2 +- .../admin/insights_helper_spec.rb | 13 ++++--- 4 files changed, 41 insertions(+), 16 deletions(-) diff --git a/app/helpers/decidim/extra_user_fields/admin/insights_helper.rb b/app/helpers/decidim/extra_user_fields/admin/insights_helper.rb index 296f1ece..30627553 100644 --- a/app/helpers/decidim/extra_user_fields/admin/insights_helper.rb +++ b/app/helpers/decidim/extra_user_fields/admin/insights_helper.rb @@ -36,6 +36,7 @@ def field_label(field_name) # Tries field-specific i18n keys first (e.g., genders.female), falls back to humanize. def field_value_label(field_name, value) return t("decidim.admin.extra_user_fields.insights.non_specified") if value.nil? + return country_name_from_code(value) if field_name.to_s == "country" key = i18n_key_for_field_value(field_name, value) t(key, default: value.humanize) @@ -53,6 +54,13 @@ def i18n_key_for_field_value(field_name, value) "decidim.admin.extra_user_fields.insights.field_values.#{field_name}.#{value}" end end + + def country_name_from_code(value) + country = ISO3166::Country[value.to_s.strip.upcase] + return value.to_s.humanize unless country + + country.translations[I18n.locale.to_s] || country.common_name || country.iso_short_name + end end end end diff --git a/app/packs/stylesheets/decidim/extra_user_fields/insights.scss b/app/packs/stylesheets/decidim/extra_user_fields/insights.scss index 99a8d0e9..a65ac5d0 100644 --- a/app/packs/stylesheets/decidim/extra_user_fields/insights.scss +++ b/app/packs/stylesheets/decidim/extra_user_fields/insights.scss @@ -119,6 +119,18 @@ // Legend .insights-legend { + --insights-heat-1: hsl(50, 90%, 86%); + --insights-heat-2: hsl(38, 87%, 77%); + --insights-heat-3: hsl(25, 84%, 68%); + --insights-heat-4: hsl(12, 81%, 59%); + --insights-heat-5: hsl(0, 78%, 50%); + --insights-total-1: hsl(215, 53%, 87%); + --insights-total-2: hsl(215, 57%, 77%); + --insights-total-3: hsl(215, 61%, 67%); + --insights-total-4: hsl(215, 63%, 57%); + --insights-total-5: hsl(215, 65%, 50%); + --insights-non-specified: hsl(0, 0%, 80%); + @apply flex items-center gap-3 mt-3 text-sm text-gray-2; &__gradient { @@ -131,45 +143,45 @@ &--heat { .insights-legend__gradient-step:nth-child(1) { - background-color: hsl(50, 90%, 86%); + background-color: var(--insights-heat-1); } .insights-legend__gradient-step:nth-child(2) { - background-color: hsl(38, 87%, 77%); + background-color: var(--insights-heat-2); } .insights-legend__gradient-step:nth-child(3) { - background-color: hsl(25, 84%, 68%); + background-color: var(--insights-heat-3); } .insights-legend__gradient-step:nth-child(4) { - background-color: hsl(12, 81%, 59%); + background-color: var(--insights-heat-4); } .insights-legend__gradient-step:nth-child(5) { - background-color: hsl(0, 78%, 50%); + background-color: var(--insights-heat-5); } } &--total { .insights-legend__gradient-step:nth-child(1) { - background-color: hsl(215, 53%, 87%); + background-color: var(--insights-total-1); } .insights-legend__gradient-step:nth-child(2) { - background-color: hsl(215, 57%, 77%); + background-color: var(--insights-total-2); } .insights-legend__gradient-step:nth-child(3) { - background-color: hsl(215, 61%, 67%); + background-color: var(--insights-total-3); } .insights-legend__gradient-step:nth-child(4) { - background-color: hsl(215, 63%, 57%); + background-color: var(--insights-total-4); } .insights-legend__gradient-step:nth-child(5) { - background-color: hsl(215, 65%, 50%); + background-color: var(--insights-total-5); } } } @@ -184,6 +196,6 @@ &__non-specified-swatch { @apply inline-block w-4 h-4 rounded align-middle; - background-color: hsl(0, 0%, 80%); + background-color: var(--insights-non-specified); } } diff --git a/lib/decidim/extra_user_fields.rb b/lib/decidim/extra_user_fields.rb index 5b49d0b7..8146ac1d 100644 --- a/lib/decidim/extra_user_fields.rb +++ b/lib/decidim/extra_user_fields.rb @@ -83,7 +83,7 @@ module ExtraUserFields # Override via initializer: # Decidim::ExtraUserFields.config.insight_fields = %w(gender age_range country) config_accessor :insight_fields do - %w(gender age_range country postal_code location) + %w(gender age_range country) end # Registry of insight metrics available for pivot tables. diff --git a/spec/helpers/decidim/extra_user_fields/admin/insights_helper_spec.rb b/spec/helpers/decidim/extra_user_fields/admin/insights_helper_spec.rb index f3df7483..3709ed5d 100644 --- a/spec/helpers/decidim/extra_user_fields/admin/insights_helper_spec.rb +++ b/spec/helpers/decidim/extra_user_fields/admin/insights_helper_spec.rb @@ -23,8 +23,6 @@ module Decidim::ExtraUserFields::Admin expect(helper.field_label("gender")).to eq("Gender") expect(helper.field_label("age_range")).to eq("Age span") expect(helper.field_label("country")).to eq("Country") - expect(helper.field_label("postal_code")).to eq("Postal code") - expect(helper.field_label("location")).to eq("Location") end it "humanizes unknown field names" do @@ -50,8 +48,15 @@ module Decidim::ExtraUserFields::Admin end it "falls back to humanized value for other fields" do - expect(helper.field_value_label("country", "spain")).to eq("Spain") - expect(helper.field_value_label("location", "some_place")).to eq("Some place") + expect(helper.field_value_label("custom_field", "some_place")).to eq("Some place") + end + + it "translates country codes to country names" do + expect(helper.field_value_label("country", "DE")).to eq("Germany") + end + + it "falls back to humanized value for unknown country codes" do + expect(helper.field_value_label("country", "unknown_code")).to eq("Unknown code") end end From 09c95f6641ee62eec0efa328f11ce7556573226b Mon Sep 17 00:00:00 2001 From: Anna Topalidi Date: Thu, 26 Feb 2026 16:12:33 +0100 Subject: [PATCH 13/15] refactoring --- .../admin/insights_controller.rb | 20 +++--- .../admin/insights_helper.rb | 5 +- .../pivot_table_presenter.rb | 26 +++---- .../extra_user_fields/insight_metrics.rb | 4 +- .../extra_user_fields/metrics/base_metric.rb | 72 +------------------ .../metrics/budget_votes_metric.rb | 8 +-- .../metrics/comments_metric.rb | 6 +- .../metrics/concerns/budget_queries.rb | 47 ++++++++++++ .../metrics/concerns/comment_queries.rb | 39 ++++++++++ .../metrics/concerns/proposal_queries.rb | 43 +++++++++++ .../metrics/participants_metric.rb | 47 +++--------- .../metrics/proposals_created_metric.rb | 9 +-- .../metrics/proposals_supported_metric.rb | 7 +- .../extra_user_fields/pivot_table_builder.rb | 6 +- .../metrics/budget_votes_metric_spec.rb | 29 ++++++++ .../metrics/comments_metric_spec.rb | 70 ++++++++++++++++++ .../metrics/participants_metric_spec.rb | 48 +++++++++++++ .../metrics/proposals_created_metric_spec.rb | 22 ++++++ .../proposals_supported_metric_spec.rb | 39 ++++++++++ 19 files changed, 387 insertions(+), 160 deletions(-) create mode 100644 app/queries/decidim/extra_user_fields/metrics/concerns/budget_queries.rb create mode 100644 app/queries/decidim/extra_user_fields/metrics/concerns/comment_queries.rb create mode 100644 app/queries/decidim/extra_user_fields/metrics/concerns/proposal_queries.rb diff --git a/app/controllers/decidim/extra_user_fields/admin/insights_controller.rb b/app/controllers/decidim/extra_user_fields/admin/insights_controller.rb index 2bcab252..90397067 100644 --- a/app/controllers/decidim/extra_user_fields/admin/insights_controller.rb +++ b/app/controllers/decidim/extra_user_fields/admin/insights_controller.rb @@ -31,18 +31,15 @@ def pivot_table_presenter end def current_metric - @current_metric ||= begin - metric = params[:metric].to_s - metric if InsightMetrics.valid_metric?(metric) - end || available_metrics.first + @current_metric ||= detect_metric(params[:metric]) || available_metrics.first end def current_row_field - @current_row_field ||= validated_field(params[:rows]) || available_fields.first + @current_row_field ||= detect_field(params[:rows]) || available_fields.first end def current_col_field - @current_col_field ||= validated_field(params[:cols]) || available_fields.second + @current_col_field ||= detect_field(params[:cols]) || available_fields.second end def available_metrics @@ -53,9 +50,14 @@ def available_fields @available_fields ||= Decidim::ExtraUserFields.insight_fields end - def validated_field(value) - field = value.to_s - field if available_fields.include?(field) + def detect_metric(name) + name = name.to_s + name if InsightMetrics.valid_metric?(name) + end + + def detect_field(name) + name = name.to_s + name if available_fields.include?(name) end def permission_class_chain diff --git a/app/helpers/decidim/extra_user_fields/admin/insights_helper.rb b/app/helpers/decidim/extra_user_fields/admin/insights_helper.rb index 30627553..7f603e82 100644 --- a/app/helpers/decidim/extra_user_fields/admin/insights_helper.rb +++ b/app/helpers/decidim/extra_user_fields/admin/insights_helper.rb @@ -4,7 +4,7 @@ module Decidim module ExtraUserFields module Admin module InsightsHelper - # Render a compact selector: bordered frame with "Label: [value ▾]". + # Renders a labeled