From ace37a23d223493e76014bae34c05208ea3072a3 Mon Sep 17 00:00:00 2001 From: Mattias Bodlund Date: Tue, 25 Aug 2026 11:12:04 +0200 Subject: [PATCH] na --- AGENTS.md | 35 ++- app/assets/stylesheets/analytics.css | 270 ++++++++++++++++ app/assets/stylesheets/application.css | 8 + app/controllers/admin/analytics_controller.rb | 17 + app/controllers/game_controller.rb | 29 ++ app/helpers/admin/analytics_helper.rb | 26 ++ app/javascript/application.js | 2 + app/javascript/rating_controller.js | 31 ++ app/models/player.rb | 50 +++ app/services/demo_activity.rb | 2 +- app/services/game_analytics.rb | 290 ++++++++++++++++++ app/views/admin/analytics/_breakdown.html.erb | 22 ++ app/views/admin/analytics/_card.html.erb | 7 + app/views/admin/analytics/index.html.erb | 222 ++++++++++++++ app/views/game/results.html.erb | 12 +- app/views/layouts/admin.html.erb | 4 +- app/views/layouts/application.html.erb | 2 +- config/importmap.rb | 1 + config/locales/en.yml | 1 + config/routes.rb | 4 + ...20260825120000_add_analytics_to_players.rb | 15 + ...825120001_backfill_player_furthest_step.rb | 29 ++ db/schema.rb | 8 +- 23 files changed, 1078 insertions(+), 9 deletions(-) create mode 100644 app/assets/stylesheets/analytics.css create mode 100644 app/controllers/admin/analytics_controller.rb create mode 100644 app/helpers/admin/analytics_helper.rb create mode 100644 app/javascript/rating_controller.js create mode 100644 app/services/game_analytics.rb create mode 100644 app/views/admin/analytics/_breakdown.html.erb create mode 100644 app/views/admin/analytics/_card.html.erb create mode 100644 app/views/admin/analytics/index.html.erb create mode 100644 db/migrate/20260825120000_add_analytics_to_players.rb create mode 100644 db/migrate/20260825120001_backfill_player_furthest_step.rb diff --git a/AGENTS.md b/AGENTS.md index c1cb242..b1e0c18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,8 @@ This project is a Ruby on Rails application developed for the IKEA Foundation. I - **Stage result:** Shows the outcome node and applies its score. - **Last save / Results:** Final compost-vs-landfill choice, then the scored results screen. - **Scoring:** Answer scores live in `config/question_scores.json`, not in the database. See below. +- **Analytics:** An admin dashboard at `/admin/:locale/analytics` reporting funnel drop-off, + answer distribution and the results-screen thumbs vote. See below. - **Admin Interface:** A backend for managing nodes, assets (Active Storage), users, and translations. - **Search:** `pg_search` integration for content discovery. @@ -32,7 +34,9 @@ This project is a Ruby on Rails application developed for the IKEA Foundation. I - **Level 2 (answers under a stage):** `good_answer`, `bad_answer`, `chance` - **Level 3 (outcomes under a chance):** `good_answer`, `bad_answer` - `Player`: Tracks session state, `progress` (per-stage `answer_id` / `result_id`), the - cumulative `score`, and the `scores` hash keyed by `food_waste`, `emissions`, `income`. + cumulative `score`, the `scores` hash keyed by `food_waste`, `emissions`, `income`, + the furthest screen reached (`furthest_step`) and the results-screen thumbs vote + (`rating` / `rated_at`). - `Asset` & `Attachment`: Handles media and its contextual content (body text, styling) associated with nodes. - `User`: Admin authentication and roles. @@ -78,12 +82,41 @@ because nothing raises: `neutral` was a verbatim copy of `positive` in every loc `income: 0` player was told they "earned some money". When touching these, sweep all locales and check the three strings actually differ, not just that the key exists. +## Analytics + +There is no event log. `GameAnalytics` derives everything from two columns on `players`: + +- `progress` — what people *did* (the answer node they landed on for each stage). +- `furthest_step` — where people *stopped*. Written by `GameController#track_step`, an + `after_action` on the screen actions. `Player#record_step` only ever moves a player + forward, so the browser back button can't rewind the funnel. + +Step names are `facts`, `intro`, `stage_`, `stage__result`, `last_save`, `done`, +`results`, ordered by `Player.step_rank`. + +**Gotcha:** stage reach is computed from `progress`, *not* by comparing `furthest_step` +ranks. 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. The last-save branch is therefore +reported as its own panel rather than as a funnel step. + +**Gotcha:** `DemoActivity` (see `app/services/demo_activity.rb`) invents players with no +gameplay to feed the leaderboard banner, which inflates every count and shows up as a huge +drop before "Facts". The dashboard warns about this while `DemoActivity::ENABLED` is true. + +### Thumbs up / down + +`Player#rate!` stores `rating` as `1` / `-1` (`Player::RATINGS`), one per player — voting +again overwrites. The results screen posts to `game#rate` via `rating_controller.js`, which +flips the button state immediately and does not await the response. + ## Project Structure - `app/controllers/admin/`: Admin backend logic. - `app/controllers/game_controller.rb`: Main game loop (stage -> answer -> result -> results). - `app/controllers/api/`: JSON endpoints (see `docs/leaderboard_api.md`). - `app/helpers/game_helper.rb`: Result bands and per-impact tone thresholds. +- `app/services/game_analytics.rb`: Aggregates the admin analytics dashboard. +- `app/controllers/admin/analytics_controller.rb`: Admin analytics dashboard. - `app/models/concerns/`: Shared logic for ancestry, attachments, and tags. - `config/locales/`: YAML translation files. - `config/question_scores.json`: Answer scoring table. diff --git a/app/assets/stylesheets/analytics.css b/app/assets/stylesheets/analytics.css new file mode 100644 index 0000000..53cf44e --- /dev/null +++ b/app/assets/stylesheets/analytics.css @@ -0,0 +1,270 @@ +.analytics { + --analytics-positive: #1a863a; + --analytics-negative: #b24226; + --analytics-neutral: #c08a1e; + + max-width: 1100px; + padding-bottom: 4rem; +} + + +/* ----------------------------------------------------------- period picker */ + +.analytics-periods { + display: flex; + gap: 0.25rem; + align-items: center; +} + +.analytics-period { + padding: 0.35em 0.8em; + border-radius: 999px; + border: 1px solid var(--border); + color: var(--secondary); + text-decoration: none; + font-size: 0.85rem; + white-space: nowrap; + + &:hover { + background: var(--hover); + } + + &.current { + background: var(--clr-black); + border-color: var(--clr-black); + color: #fff; + } +} + + +.analytics-warning { + border: 1px solid #e0c060; + background: #fdf6e3; + border-radius: 8px; + padding: 0.8rem 1rem; + margin: 1.5rem 0 0; + font-size: 0.85rem; + line-height: 1.5; + max-width: 70ch; + + & code { + font-family: var(--font-mono); + font-size: 0.8em; + } +} + + +/* ------------------------------------------------------------------- cards */ + +.analytics-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1rem; + margin: 1.5rem 0; +} + +.analytics-card { + border: 1px solid var(--border); + border-radius: 10px; + padding: 1.1rem 1.2rem; + background: #fff; + + &.positive .analytics-card-value { color: var(--analytics-positive); } + &.negative .analytics-card-value { color: var(--analytics-negative); } +} + +.analytics-card-value { + font-size: 2rem; + line-height: 1.1; + font-weight: 500; + font-variant-numeric: tabular-nums; +} + +.analytics-card-label { + margin-top: 0.35rem; + color: var(--secondary); + font-size: 0.85rem; +} + +.analytics-card-sub { + margin-top: 0.2rem; + color: var(--clr-grey-400); + font-size: 0.78rem; +} + + +/* ------------------------------------------------------------------ panels */ + +.analytics-panel { + border-top: 1px solid var(--border); + padding-top: 1.5rem; + margin-top: 2.5rem; + + & > h2 { + font-size: 1.1rem; + font-weight: 500; + margin: 0 0 0.25rem; + + & small { + color: var(--clr-grey-400); + font-weight: 400; + font-size: 0.8rem; + margin-left: 0.5rem; + } + } + + & .analytics-cards { + margin-top: 1rem; + } +} + +.analytics-note, +.analytics-empty { + color: var(--secondary); + font-size: 0.85rem; + margin: 0.25rem 0 0; + max-width: 60ch; +} + +.analytics-empty { + padding: 1rem 0; + font-style: italic; +} + +.analytics-columns { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 0 2.5rem; +} + + +/* -------------------------------------------------------------------- bars */ + +.analytics-bars { + margin-top: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +/* Narrow two-column breakdowns have no delta column, and the label and value + columns have to shrink or the track collapses to nothing. */ +.analytics-bars--compact .analytics-bar-row { + grid-template-columns: minmax(80px, 12rem) 1fr 6.5rem; + gap: 0.6rem; +} + +.analytics-bar-row { + display: grid; + grid-template-columns: minmax(140px, 22rem) 1fr 8.5rem 6rem; + align-items: center; + gap: 0.85rem; + font-size: 0.85rem; + padding: 0.25rem 0; + + &.is-outcome { + font-size: 0.8rem; + opacity: 0.8; + + & .analytics-bar-label { + padding-left: 1rem; + } + } +} + +.analytics-bar-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + & em { + color: var(--clr-grey-400); + font-style: normal; + font-size: 0.72rem; + margin-left: 0.4rem; + } +} + +.analytics-bar-track { + background: var(--clr-grey-200); + border-radius: 3px; + height: 12px; + overflow: hidden; +} + +.analytics-bar-fill { + height: 100%; + background: var(--action); + border-radius: 3px; + transition: width 0.2s ease; + + &.positive { background: var(--analytics-positive); } + &.negative { background: var(--analytics-negative); } + &.neutral { background: var(--analytics-neutral); } +} + +.analytics-bar-value { + font-variant-numeric: tabular-nums; + white-space: nowrap; + + & span { + color: var(--secondary); + margin-left: 0.4rem; + font-size: 0.78rem; + } +} + +.analytics-bar-delta { + font-variant-numeric: tabular-nums; + font-size: 0.78rem; + text-align: right; + + & .negative { color: var(--analytics-negative); } +} + + +/* ------------------------------------------------------------ split & stage */ + +.analytics-split { + display: flex; + height: 14px; + border-radius: 3px; + overflow: hidden; + margin-top: 1.25rem; + background: var(--clr-grey-200); +} + +.analytics-split-part { + &.positive { background: var(--analytics-positive); } + &.negative { background: var(--analytics-negative); } +} + +.analytics-stage { + margin-top: 2rem; + + & > h3 { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.95rem; + font-weight: 500; + margin: 0; + + & small { + color: var(--clr-grey-400); + font-weight: 400; + font-size: 0.78rem; + } + } +} + +.analytics-stage-index { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + aspect-ratio: 1; + border-radius: 50%; + background: var(--clr-grey-200); + font-size: 0.75rem; +} diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 987ab9b..b0a4841 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -919,6 +919,14 @@ dialog::backdrop { rotate: 180deg; } } + + &.is-selected { + background-color: var(--clr-black); + + & svg { + fill: #fff; + } + } } } diff --git a/app/controllers/admin/analytics_controller.rb b/app/controllers/admin/analytics_controller.rb new file mode 100644 index 0000000..143d6a8 --- /dev/null +++ b/app/controllers/admin/analytics_controller.rb @@ -0,0 +1,17 @@ +class Admin::AnalyticsController < Admin::AdminController + + helper_method :period + + # GET /admin/:locale/analytics + def index + @analytics = GameAnalytics.new(period: period) + end + + +private + + + def period + @period ||= params[:period].to_s.presence_in(GameAnalytics::PERIODS.keys) || "all" + end +end diff --git a/app/controllers/game_controller.rb b/app/controllers/game_controller.rb index 2c41656..79248c2 100644 --- a/app/controllers/game_controller.rb +++ b/app/controllers/game_controller.rb @@ -9,6 +9,7 @@ class GameController < ApplicationController layout "application" before_action :set_node, only: [ :stage, :answer, :stage_result ] + after_action :track_step, only: [ :facts, :intro, :stage, :stage_result, :last_save, :done, :results ] helper_method :root_node, :stage_index, @@ -116,8 +117,36 @@ class GameController < ApplicationController end + # POST -- thumbs up / down on the results screen. One rating per player; + # tapping the other thumb (or the same one again) just overwrites it. + def rate + current_player.rate!(params[:direction]) + + head :no_content + end + + private + + # Records the furthest screen this player reached, so the admin funnel can + # show drop-off for people who never answered anything. + def track_step + return unless response.successful? + + current_player.record_step(step_name) + end + + + def step_name + case action_name + when "stage" then "stage_#{stage_index}" + when "stage_result" then "stage_#{stage_index}_result" + else action_name + end + end + + def root_node @root_node ||= Node.roots.viewable.first end diff --git a/app/helpers/admin/analytics_helper.rb b/app/helpers/admin/analytics_helper.rb new file mode 100644 index 0000000..b65c9f1 --- /dev/null +++ b/app/helpers/admin/analytics_helper.rb @@ -0,0 +1,26 @@ +module Admin::AnalyticsHelper + + # Colours a bar by what the answer does to the player's score, so a stage + # reads at a glance: green = they made the good call, red = they didn't. + def answer_tone(node) + case node.template + when "good_answer" then "positive" + when "bad_answer" then "negative" + when "chance" then "neutral" + end + end + + + def band_tone(band) + { best: "positive", balanced: "neutral", worst: "negative" }[band.to_sym] + end + + + def format_duration(seconds) + return "—" if seconds.blank? || seconds.to_i.zero? + + seconds = seconds.to_i + minutes, seconds = seconds.divmod(60) + minutes.zero? ? "#{seconds}s" : "#{minutes}m #{seconds}s" + end +end diff --git a/app/javascript/application.js b/app/javascript/application.js index 66f6764..d9197b6 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -7,6 +7,7 @@ import IntroController from "intro_controller" import ChanceController from "chance_controller" import ShareController from "share_controller" import ModalController from "modal_controller" +import RatingController from "rating_controller" const application = Application.start() @@ -16,6 +17,7 @@ application.register("intro", IntroController) application.register("chance", ChanceController) application.register("share", ShareController) application.register("modal", ModalController) +application.register("rating", RatingController) // The whole game runs inside one turbo-frame so the URL never changes. Frame // navigations don't reset scroll the way a full page visit does, so each new diff --git a/app/javascript/rating_controller.js b/app/javascript/rating_controller.js new file mode 100644 index 0000000..a19518a --- /dev/null +++ b/app/javascript/rating_controller.js @@ -0,0 +1,31 @@ +import { Controller } from "@hotwired/stimulus" + +// Thumbs up / down on the results screen. Fire-and-forget: the button state +// flips immediately and the POST is not awaited, so a slow network never makes +// the tap feel dead. One rating per player -- tapping the other thumb just +// overwrites the previous one server-side. +export default class extends Controller { + static targets = ["button"] + static values = { url: String } + + rate(event) { + const button = event.currentTarget + const direction = button.dataset.direction + + this.buttonTargets.forEach((b) => b.classList.toggle("is-selected", b === button)) + + const body = new FormData() + body.append("direction", direction) + + fetch(this.urlValue, { + method: "POST", + body, + headers: { "X-CSRF-Token": this.csrfToken }, + credentials: "same-origin" + }).catch(() => {}) + } + + get csrfToken() { + return document.querySelector("meta[name='csrf-token']")?.content || "" + } +} diff --git a/app/models/player.rb b/app/models/player.rb index b82db5f..d7114cf 100644 --- a/app/models/player.rb +++ b/app/models/player.rb @@ -7,6 +7,56 @@ class Player < ApplicationRecord SCORE_KEYS = %w[food_waste emissions income].freeze + RATINGS = { up: 1, down: -1 }.freeze + + # Rank of every fixed screen, in the order the game walks them. Stage screens + # are dynamic ("stage_3", "stage_3_result") so they get a rank computed from + # the stage number instead of a fixed slot here. + STEP_RANKS = { "start" => 0, "facts" => 1, "intro" => 2, + "last_save" => 900, "done" => 901, "results" => 902 }.freeze + + scope :rated, -> { where.not(rating: nil) } + scope :thumbs_up, -> { where(rating: RATINGS[:up]) } + scope :thumbs_down, -> { where(rating: RATINGS[:down]) } + + + # Stage screens interleave: stage 1 (10), its result (11), stage 2 (12)... + # Everything before stages sits below 10, everything after above 900. + def self.step_rank(step) + step = step.to_s + return STEP_RANKS[step] if STEP_RANKS.key?(step) + + if (m = step.match(/\Astage_(\d+)(_result)?\z/)) + 10 + (m[1].to_i * 2) + (m[2] ? 1 : 0) + else + -1 + end + end + + + # Never moves a player backwards -- replaying an earlier screen (or the + # browser back button) must not undo the funnel position they reached. + def record_step(step) + step = step.to_s + return if self.class.step_rank(step) <= self.class.step_rank(furthest_step) + + update_columns(furthest_step: step, updated_at: Time.current) + end + + + def rate!(direction) + value = RATINGS[direction.to_s.to_sym] + return false unless value + + update(rating: value, rated_at: Time.current) + end + + + def rating_direction + RATINGS.key(rating) + end + + # Normalises an arbitrary country code to one of the leaderboard's known # countries, or nil if it isn't one we track. Shared by the game controller diff --git a/app/services/demo_activity.rb b/app/services/demo_activity.rb index ba96e30..a7d8046 100644 --- a/app/services/demo_activity.rb +++ b/app/services/demo_activity.rb @@ -5,7 +5,7 @@ # TEMPORARY: set ENABLED = false (or delete this) and wipe the players table # before the game goes live. class DemoActivity - ENABLED = true + ENABLED = false def self.simulate! return unless ENABLED diff --git a/app/services/game_analytics.rb b/app/services/game_analytics.rb new file mode 100644 index 0000000..c0ca58d --- /dev/null +++ b/app/services/game_analytics.rb @@ -0,0 +1,290 @@ +# 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 + + 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 + + + def by_country + @by_country ||= breakdown(players.where.not(country: nil).group(:country).count) + 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 diff --git a/app/views/admin/analytics/_breakdown.html.erb b/app/views/admin/analytics/_breakdown.html.erb new file mode 100644 index 0000000..3a98ac6 --- /dev/null +++ b/app/views/admin/analytics/_breakdown.html.erb @@ -0,0 +1,22 @@ +
+

<%= title %>

+ + <% if rows.empty? %> +

Nothing recorded yet.

+ <% else %> +
+ <% rows.each do |row| %> +
+
<%= labeller.call(row[:key]) %>
+
+
+
+
+ <%= number_with_delimiter(row[:count]) %> + <%= row[:share] %>% +
+
+ <% end %> +
+ <% end %> +
diff --git a/app/views/admin/analytics/_card.html.erb b/app/views/admin/analytics/_card.html.erb new file mode 100644 index 0000000..1b56c60 --- /dev/null +++ b/app/views/admin/analytics/_card.html.erb @@ -0,0 +1,7 @@ +
+
<%= value %>
+
<%= label %>
+ <% if local_assigns[:sub].present? %> +
<%= sub %>
+ <% end %> +
diff --git a/app/views/admin/analytics/index.html.erb b/app/views/admin/analytics/index.html.erb new file mode 100644 index 0000000..1899856 --- /dev/null +++ b/app/views/admin/analytics/index.html.erb @@ -0,0 +1,222 @@ +<%= content_for :title, "Analytics" %> + +<%= turbo_frame_tag 'main' do %> + + <%= turbo_stream.append 'flash', partial: 'layouts/flash' %> + +
+

<%= yield(:title) %>

+ +
+ <% { "all" => "All time", "30d" => "30 days", "7d" => "7 days", "24h" => "24 hours" }.each do |key, label| %> + <%= link_to label, + url_for(period: key), + class: class_names("analytics-period", current: period == key), + data: { turbo_frame: "main", turbo_action: "advance" } %> + <% end %> +
+
+ +
+ + <% if DemoActivity::ENABLED %> +

+ Demo data is on. + DemoActivity keeps inventing players with no gameplay to feed the leaderboard + banner, so the counts below are inflated and the funnel shows a large drop before "Facts". + Set DemoActivity::ENABLED = false and wipe the players table before go-live. +

+ <% end %> + +
+ <%= render "card", label: "Players started", value: number_with_delimiter(@analytics.total_players) %> + <%= render "card", label: "Finished", value: number_with_delimiter(@analytics.completed_players), + sub: "#{@analytics.completion_rate}% of starters" %> + <%= render "card", label: "Median play time", value: format_duration(@analytics.median_duration) %> + <%= render "card", label: "Average score", value: (@analytics.average_score || "—") %> +
+ + +
+

Did you like this game?

+ + <% ratings = @analytics.ratings %> + <% if ratings[:total].zero? %> +

No votes yet.

+ <% else %> +
+ <%= render "card", label: "Thumbs up", value: number_with_delimiter(ratings[:up]), + sub: "#{ratings[:up_share]}%", modifier: "positive" %> + <%= render "card", label: "Thumbs down", value: number_with_delimiter(ratings[:down]), + sub: "#{ratings[:down_share]}%", modifier: "negative" %> + <%= render "card", label: "Votes", value: number_with_delimiter(ratings[:total]), + sub: "#{ratings[:response_rate]}% of players who reached the results" %> +
+ +
+
+
+
+ <% end %> +
+ + +
+

Funnel

+

+ How far players got. The last-save early exit is a branch off the stages, so it is + reported separately below rather than as a funnel step. +

+ +
+ <% @analytics.funnel.each do |row| %> +
+
<%= row[:label] %>
+
+
+
+
+ <%= number_with_delimiter(row[:reached]) %> + <%= row[:share] %>% +
+
+ <% if row[:lost] > 0 %> + -<%= number_with_delimiter(row[:lost]) %> (<%= row[:lost_share] %>%) + <% end %> +
+
+ <% end %> +
+
+ + +
+

Where unfinished players stopped

+ + <% if @analytics.drop_off_points.empty? %> +

Nobody has abandoned a game yet.

+ <% else %> +
+ <% max = @analytics.drop_off_points.first[:count] %> + <% @analytics.drop_off_points.each do |row| %> +
+
<%= row[:label] %>
+
+
+
+
<%= number_with_delimiter(row[:count]) %>
+
+
+ <% end %> +
+ <% end %> +
+ + +
+

Answers by stage

+ + <% @analytics.stages.each do |stage| %> +
+

+ <%= stage[:index] %> + <%= stage[:node].title %> + <%= number_with_delimiter(stage[:total]) %> answers +

+ +
+ <% stage[:answers].each do |answer| %> +
+
+ <%= answer[:label] %> + <%= tag.em(answer[:node].template.humanize) %> +
+
+
+
+
+ <%= number_with_delimiter(answer[:count]) %> + <%= answer[:share] %>% +
+
+
+ + <% answer[:outcomes].each do |outcome| %> +
+
+ ↳ <%= outcome[:label] %> + <%= tag.em(outcome[:node].template.humanize) %> +
+
+
+
+
+ <%= number_with_delimiter(outcome[:count]) %> + <%= outcome[:share] %>% of chances +
+
+
+ <% end %> + <% end %> +
+
+ <% end %> +
+ + + <% if @analytics.last_save[:node] %> +
+

Last save early exit — <%= number_with_delimiter(@analytics.last_save[:total]) %> players

+ + <% if @analytics.last_save[:total].zero? %> +

No player has taken the early exit yet.

+ <% else %> +
+ <% @analytics.last_save[:answers].each do |answer| %> +
+
<%= answer[:label] %>
+
+
+
+
+ <%= number_with_delimiter(answer[:count]) %> + <%= answer[:share] %>% +
+
+
+ <% end %> +
+ <% end %> +
+ <% end %> + + +
+

Endings finished players, excluding early exits

+ +
+ <% @analytics.result_bands.each do |band| %> +
+
<%= t("game.results.#{band[:band]}.headline", default: band[:band].to_s.humanize).html_safe %>
+
+
+
+
+ <%= number_with_delimiter(band[:count]) %> + <%= band[:share] %>% +
+
+
+ <% end %> +
+
+ + +
+ <%= render "breakdown", title: "Languages", rows: @analytics.by_locale, labeller: ->(key) { key } %> + <%= render "breakdown", title: "Countries", rows: @analytics.by_country, + labeller: ->(key) { t("countries.#{key}", default: key.to_s.upcase) } %> +
+ +
+<% end %> diff --git a/app/views/game/results.html.erb b/app/views/game/results.html.erb index 507c955..21c54d7 100644 --- a/app/views/game/results.html.erb +++ b/app/views/game/results.html.erb @@ -64,13 +64,19 @@ -