mirror of
https://github.com/discourse/discourse.git
synced 2026-08-08 17:53:55 +08:00
The engagement dashboard's "Trust level pipeline" showed each level's
membership
plus direction-blind "moves in / moves out" counts, colored by
**position**
(in vs. out). That mislabeled healthy movement: a member graduating
*out* of Tiers, especially New,
rendered as a red ↓, reading as negative when it's the best possible
outcome.
This PR reframes the widget as a **directional arrivals funnel** with a
focus on the flow, and specifically looks at promoted/demoted-in
## Design thinking
- **Direction, not position.** Trust-level movement has a meaningful
axis —
up (promotion) is good, down (demotion) is not. We split every move into
`promoted_in` and `demoted_in`, drop the double bars, and colour by
direction: if more promotions => green, if more demotions => red.
Leaving New is now correctly green.
- **An "arrivals" funnel.** Each rung answers one consistent question:
*how many
members arrived here this period?* For every bar it's tracked the same
way + we add an extra label for the tier that is set as the `Default
trust level`.
- **Sign-ups ≠ promotions.** Signups are excluded from both the trend
and the bar scale. It's volume would otherwise often dwarf the other
bars.
## Backend — `reports/trust_level_pipeline.rb`
- **Snapshot** per level: `User.real.group(:trust_level).count` + share.
No date
filter — it's the current distribution, not a period metric.
- **Directional arrivals** from `user_histories` (`change_trust_level` +
`auto_trust_level_change`) within the period: `promoted_in`,
`demoted_in` per level.
- **Sign-ups**: real users created in the period, attributed to the
entry level
(`SiteSetting.default_trust_level`). Not counted as trust-level moves.
- **Trend** (`prev_period`): net = promotions − demotions across the
ladder
(sign-ups excluded) → `climbing` / `dropping` / `stable`.
## Known limitation
The `Default invitee trust level` is not taken into account, which means
invitees can be counted at the wrong rung.
However, accounting for it correctly means distinguishing invited from
organic sign-ups (joining through invites, and handling that a user's
trust level may have moved since they joined), which introduces a second
entry point. That breaks the widget's core simplification and adds
complexity. Overall I'm expecting the % of invitees to usually not
meaningfully muddle the representation of the pipeline flow.
162 lines
5.2 KiB
Ruby
Vendored
162 lines
5.2 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
describe Reports::TrustLevelPipeline do
|
|
before { freeze_time(Time.zone.local(2026, 4, 28, 12, 0, 0)) }
|
|
|
|
let(:start_date) { Time.zone.local(2026, 4, 1) }
|
|
let(:end_date) { Time.zone.local(2026, 4, 28).end_of_day }
|
|
|
|
def build
|
|
Report.find("trust_level_pipeline", start_date: start_date, end_date: end_date)
|
|
end
|
|
|
|
def row(report, tl)
|
|
report.data.find { |r| r[:trust_level] == tl }
|
|
end
|
|
|
|
def record_promotion(user, from:, to:, at:, action: :change_trust_level)
|
|
UserHistory.create!(
|
|
action: UserHistory.actions[action],
|
|
target_user_id: user.id,
|
|
previous_value: from.to_s,
|
|
new_value: to.to_s,
|
|
created_at: at,
|
|
)
|
|
end
|
|
|
|
it "snapshots the current member count and share per trust level" do
|
|
Fabricate(:user, trust_level: TrustLevel[1])
|
|
Fabricate(:user, trust_level: TrustLevel[1])
|
|
Fabricate(:user, trust_level: TrustLevel[2])
|
|
Fabricate(:user, trust_level: TrustLevel[4])
|
|
|
|
report = build
|
|
|
|
expect(row(report, 1)[:count]).to eq(2)
|
|
expect(row(report, 2)[:count]).to eq(1)
|
|
expect(row(report, 4)[:count]).to eq(1)
|
|
expect(report.data.sum { |r| r[:share] }).to be_within(0.1).of(100.0)
|
|
end
|
|
|
|
it "counts promotions as arrivals at the destination level" do
|
|
user = Fabricate(:user, trust_level: TrustLevel[2])
|
|
record_promotion(user, from: 1, to: 2, at: start_date + 1.day)
|
|
record_promotion(user, from: 2, to: 3, at: start_date + 5.days)
|
|
|
|
report = build
|
|
|
|
expect(row(report, 2)[:promoted_in]).to eq(1)
|
|
expect(row(report, 3)[:promoted_in]).to eq(1)
|
|
expect(report.data.sum { |r| r[:demoted_in] }).to eq(0)
|
|
end
|
|
|
|
it "counts auto_trust_level_change as well as manual change_trust_level" do
|
|
user = Fabricate(:user, trust_level: TrustLevel[2])
|
|
record_promotion(user, from: 1, to: 2, at: start_date + 1.day, action: :auto_trust_level_change)
|
|
|
|
report = build
|
|
|
|
expect(row(report, 2)[:promoted_in]).to eq(1)
|
|
end
|
|
|
|
it "records a downward move as a demotion, not a promotion" do
|
|
user = Fabricate(:user, trust_level: TrustLevel[1])
|
|
record_promotion(user, from: 1, to: 2, at: start_date + 2.days)
|
|
record_promotion(user, from: 2, to: 1, at: start_date + 8.days)
|
|
|
|
report = build
|
|
|
|
expect(row(report, 2)[:promoted_in]).to eq(1)
|
|
expect(row(report, 1)[:demoted_in]).to eq(1)
|
|
expect(row(report, 1)[:promoted_in]).to eq(0)
|
|
end
|
|
|
|
it "counts members who joined in the period as sign-ups at the entry level" do
|
|
Fabricate(:user, created_at: start_date + 3.days)
|
|
Fabricate(:user, created_at: start_date + 10.days)
|
|
Fabricate(:user, created_at: start_date - 5.days)
|
|
|
|
report = build
|
|
|
|
entry = row(report, SiteSetting.default_trust_level)
|
|
expect(entry[:signups]).to eq(2)
|
|
expect(row(report, 4)[:signups]).to eq(0)
|
|
end
|
|
|
|
it "attributes sign-ups to the configured entry level, not to trust level 0" do
|
|
SiteSetting.default_trust_level = TrustLevel[1]
|
|
Fabricate(:user, created_at: start_date + 3.days)
|
|
|
|
report = build
|
|
|
|
expect(row(report, 1)[:signups]).to eq(1)
|
|
expect(row(report, 0)[:signups]).to eq(0)
|
|
end
|
|
|
|
it "does not count new sign-ups as trust-level moves" do
|
|
Fabricate(:user, created_at: start_date + 3.days)
|
|
|
|
report = build
|
|
|
|
expect(row(report, 0)[:promoted_in]).to eq(0)
|
|
expect(row(report, 0)[:demoted_in]).to eq(0)
|
|
end
|
|
|
|
it "ignores history rows outside the period" do
|
|
user = Fabricate(:user, trust_level: TrustLevel[2])
|
|
record_promotion(user, from: 1, to: 2, at: start_date - 5.days)
|
|
record_promotion(user, from: 2, to: 3, at: end_date + 5.days)
|
|
|
|
report = build
|
|
|
|
expect(row(report, 2)[:promoted_in]).to eq(0)
|
|
expect(row(report, 3)[:promoted_in]).to eq(0)
|
|
end
|
|
|
|
it "excludes the system user and bots from the snapshot" do
|
|
Discourse.system_user
|
|
Fabricate(:user, trust_level: TrustLevel[1])
|
|
|
|
report = build
|
|
|
|
real_count = report.data.sum { |r| r[:count] }
|
|
expect(real_count).to eq(1)
|
|
end
|
|
|
|
it "reports a climbing direction when net promotions exceed net demotions" do
|
|
user_a = Fabricate(:user, trust_level: TrustLevel[2])
|
|
user_b = Fabricate(:user, trust_level: TrustLevel[2])
|
|
record_promotion(user_a, from: 1, to: 2, at: start_date + 1.day)
|
|
record_promotion(user_b, from: 1, to: 2, at: start_date + 2.days)
|
|
|
|
report = build
|
|
|
|
expect(report.prev_period[:direction]).to eq("climbing")
|
|
expect(report.prev_period[:net]).to be > 0
|
|
end
|
|
|
|
it "reports a stable direction when moves cancel out" do
|
|
user_up = Fabricate(:user, trust_level: TrustLevel[2])
|
|
user_down = Fabricate(:user, trust_level: TrustLevel[1])
|
|
record_promotion(user_up, from: 1, to: 2, at: start_date + 1.day)
|
|
record_promotion(user_down, from: 2, to: 1, at: start_date + 2.days)
|
|
|
|
report = build
|
|
|
|
expect(report.prev_period[:direction]).to eq("stable")
|
|
expect(report.prev_period[:net]).to eq(0)
|
|
end
|
|
|
|
it "ignores history rows whose values aren't integer strings" do
|
|
user = Fabricate(:user, trust_level: TrustLevel[2])
|
|
UserHistory.create!(
|
|
action: UserHistory.actions[:change_trust_level],
|
|
target_user_id: user.id,
|
|
previous_value: "not_a_number",
|
|
new_value: "2",
|
|
created_at: start_date + 1.day,
|
|
)
|
|
|
|
expect { build }.not_to raise_error
|
|
end
|
|
end
|