# 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) # ---------------------------------------------------------------- ratings def ratings @ratings ||= begin counts = players.rated.group(:rating).count up = counts[Player::RATINGS[:up]].to_i down = counts[Player::RATINGS[:down]].to_i { up: up, down: down, total: up + down, up_share: percent(up, up + down), down_share: percent(down, up + down), # Share of people who saw the results screen and bothered to vote. A vote # proves the player got there, so it also acts as the floor on reach -- # players backfilled from before step tracking stop at "done". response_rate: percent(up + down, [ reached("results"), up + down ].max) } end end # ----------------------------------------------------------------- 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