You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

324 lines
11 KiB

# Gameplay analytics for the admin dashboard.
#
# Everything is derived from the `players` table -- there is no event log. Two
# sources are combined:
#
# * `progress` (JSONB) -- the answer a player picked on each stage. This is
# the record of what people *did*.
# * `furthest_step` -- the furthest screen a player reached, recorded by
# GameController#track_step. This is the record of where people *stopped*,
# including the ones who quit before answering anything.
#
# Reach for a stage deliberately does NOT use `furthest_step` ranking: the
# last-save early exit jumps a player straight to the end of the game, so a
# rank comparison would credit them with stages they never saw. Instead a stage
# counts as reached if the player answered it, or is sitting on it right now.
class GameAnalytics
include GameHelper
# Screens outside the stage loop, in the order they appear. The last-save
# branch is reported on its own rather than in the funnel -- it is an early
# exit, not a step everybody walks through.
PRE_STAGE_STEPS = %w[facts intro].freeze
POST_STAGE_STEPS = %w[done results].freeze
# Bucket for players tagged with a country we hold no headcount for.
OTHER_COUNTRY_KEY = "other".freeze
PERIODS = {
"all" => nil,
"24h" => 1.day,
"7d" => 7.days,
"30d" => 30.days
}.freeze
attr_reader :period
def initialize(period: "all")
@period = PERIODS.key?(period.to_s) ? period.to_s : "all"
end
def players
@players ||= begin
window = PERIODS[period]
window ? Player.where(created_at: window.ago..) : Player.all
end
end
def total_players = @total_players ||= players.count
def completed_players = @completed_players ||= players.where(is_done: true).count
def completion_rate = percent(completed_players, total_players)
# ----------------------------------------------------------------- funnel
# One row per screen, with how many players got that far and how many were
# lost since the previous row.
def funnel
@funnel ||= begin
rows = PRE_STAGE_STEPS.map { |step| { key: step, label: step_label(step), reached: reached(step) } }
(1..n_stages).each do |i|
rows << { key: "stage_#{i}", label: "Stage #{i}: #{stage_nodes[i - 1].title}", reached: reached_stage(i) }
end
rows += POST_STAGE_STEPS.map { |step| { key: step, label: step_label(step), reached: reached(step) } }
# Clamped at zero: a row can read higher than the one above it when older
# players predate step tracking and only have `progress` to go on.
previous = total_players
rows.map do |row|
lost = [ previous - row[:reached], 0 ].max
row.merge(
share: percent(row[:reached], total_players),
lost: lost,
lost_share: percent(lost, previous)
).tap { previous = row[:reached] }
end
end
end
# Where players who never finished gave up. Sorted worst-first so the
# stickiest screen is obvious.
def drop_off_points
@drop_off_points ||= players.where(is_done: false)
.where.not(furthest_step: nil)
.group(:furthest_step)
.count
.map { |step, count| { key: step, label: step_label(step), count: count } }
.sort_by { |row| -row[:count] }
end
# ---------------------------------------------------------------- answers
# Per stage, how the answers were split. `progress` stores the node the
# player landed on, so a chance answer is recorded as one of its *outcomes* --
# those are rolled back up under the chance node they came from.
def stages
@stages ||= stage_nodes.each_with_index.map do |node, index|
counts = answer_counts(index + 1)
answers = node.children.ordered.map { |answer| answer_row(answer, counts) }
total = answers.sum { |a| a[:count] }
{
index: index + 1,
node: node,
total: total,
answers: answers.map { |a| a.merge(share: percent(a[:count], total)) }
}
end
end
# The compost-vs-landfill choice. Only players who took the early exit ever
# see it, so `total` is much smaller than the stage totals.
def last_save
return { node: nil, total: 0, answers: [] } unless last_save_node
@last_save ||= begin
node = last_save_node
counts = players.where("jsonb_exists(progress, ?)", Player::LAST_SAVE_KEY)
.group(Arel.sql("progress->'#{Player::LAST_SAVE_KEY}'->>'answer_id'"))
.count
answers = node.children.ordered.map do |answer|
{ node: answer, label: answer.title, count: counts[answer.id.to_s].to_i }
end
total = answers.sum { |a| a[:count] }
{ node: node, total: total, answers: answers.map { |a| a.merge(share: percent(a[:count], total)) } }
end
end
# ----------------------------------------------------------------- scores
# Which ending finished players landed on. Early exits are excluded -- their
# headline comes from the last-save choice, not from the score, and they are
# already broken out in #last_save.
def result_bands
@result_bands ||= begin
scored = players.where(is_done: true)
.where("NOT jsonb_exists(progress, ?)", Player::LAST_SAVE_KEY)
.select(:score, :scores, :progress)
counts = scored.group_by { |player| result_state(player) }.transform_values(&:size)
total = counts.values.sum
%i[best balanced worst].map do |band|
{ band: band, count: counts[band].to_i, share: percent(counts[band].to_i, total) }
end
end
end
def average_score
@average_score ||= players.where(is_done: true).average(:score)&.round(1)
end
# -------------------------------------------------------------- who plays
def by_locale
@by_locale ||= breakdown(players.group(:locale).count)
end
# Only the countries we hold headcounts for are reported individually -- those
# are exactly the ones that score on the leaderboard, so they are the only ones
# we can act on. Anything else a player is tagged with is folded into a single
# "other" bucket instead of padding the list with countries we do not track.
#
# Reads the headcount file directly rather than the `countries:` key in
# en.yml, so the panel cannot drift out of sync with the leaderboard.
def by_country
@by_country ||= begin
known = LeaderboardScore.headcounts.keys.map(&:to_s)
counts = players.where.not(country: nil).group(:country).count
tracked, rest = counts.partition { |code, _count| known.include?(code.to_s) }
grouped = tracked.to_h
other = rest.sum { |_code, count| count }
grouped[OTHER_COUNTRY_KEY] = other if other.positive?
# `breakdown` sorts by size, which would strand "Other" in the middle of
# the list; it reads better pinned to the bottom.
rows = breakdown(grouped)
rows.reject { |row| row[:key] == OTHER_COUNTRY_KEY } +
rows.select { |row| row[:key] == OTHER_COUNTRY_KEY }
end
end
# Mobile / tablet / desktop, from the User-Agent at the moment the player
# started. Players we could not classify (and everyone from before device
# tracking) are dropped rather than counted, so the shares describe the
# players we actually know about.
def by_device
@by_device ||= breakdown(players.where.not(device: nil).group(:device).count)
.sort_by { |row| Player::DEVICES.index(row[:key]) || Player::DEVICES.size }
end
# How players got here. The campaign is printed on QR codes that differ only
# in `utm_content`, so that is the key -- `utm_source` is the fallback for a
# tagged link that names no specific placement, and everything untagged lands
# in one "direct" bucket.
#
# The board game QR points at dice.ikeafoundation.org, a different app, so it
# never shows up here.
def by_source
@by_source ||= begin
key = Arel.sql(
"COALESCE(NULLIF(utm_content, ''), NULLIF(utm_source, ''), '#{Player::DIRECT_SOURCE_KEY}')"
)
rows = breakdown(players.group(key).count)
# `breakdown` sorts by size and "direct" is usually the biggest row of
# all, which would push the QR codes -- the thing this panel is for --
# below the fold. Pinned to the bottom instead, like "Other" countries.
rows.reject { |row| row[:key] == Player::DIRECT_SOURCE_KEY } +
rows.select { |row| row[:key] == Player::DIRECT_SOURCE_KEY }
end
end
# Rough time-on-task: a player row is touched on every screen, so the gap
# between created_at and updated_at is how long they were playing.
def median_duration
@median_duration ||= players.where(is_done: true).pick(
Arel.sql("percentile_cont(0.5) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (updated_at - created_at)))")
)&.round
end
private
def root_node
@root_node ||= Node.roots.viewable.first
end
def stage_nodes
@stage_nodes ||= root_node ? root_node.children.ordered.stage.to_a : []
end
def last_save_node
@last_save_node ||= root_node&.children&.last_save&.first
end
def n_stages = stage_nodes.size
# Cumulative reach for the fixed screens: anyone whose furthest step ranks at
# or above this one passed through it.
def reached(step)
rank = Player.step_rank(step)
@reach_by_rank ||= players.where.not(furthest_step: nil)
.group(:furthest_step)
.count
.transform_keys { |s| Player.step_rank(s) }
@reach_by_rank.sum { |r, count| r >= rank ? count : 0 }
end
def reached_stage(index)
players.where("jsonb_exists(progress, ?) OR furthest_step = ?", index.to_s, "stage_#{index}").count
end
def answer_counts(stage_index)
players.where("jsonb_exists(progress, ?)", stage_index.to_s)
.group(Arel.sql("progress->'#{stage_index.to_i}'->>'answer_id'"))
.count
end
def answer_row(answer, counts)
if answer.chance?
outcomes = answer.children.ordered.map do |outcome|
{ node: outcome, label: outcome.title, count: counts[outcome.id.to_s].to_i }
end
total = outcomes.sum { |o| o[:count] }
{ node: answer, label: answer.title, chance: true, count: total,
outcomes: outcomes.map { |o| o.merge(share: percent(o[:count], total)) } }
else
{ node: answer, label: answer.title, chance: false,
count: counts[answer.id.to_s].to_i, outcomes: [] }
end
end
def breakdown(counts)
total = counts.values.sum
counts.sort_by { |_key, count| -count }
.map { |key, count| { key: key, count: count, share: percent(count, total) } }
end
def step_label(step)
case step
when "facts" then "Facts"
when "intro" then "Intro"
when "last_save" then "Last save"
when "done" then "Final screen"
when "results" then "Results"
when /\Astage_(\d+)_result\z/ then "Stage #{$1} result"
when /\Astage_(\d+)\z/ then "Stage #{$1}"
else step.to_s.humanize
end
end
def percent(part, whole)
return 0.0 if whole.to_i.zero?
(part.to_f / whole * 100).round(1)
end
end