Mattias Bodlund 3 weeks ago
parent
commit
ace37a23d2
23 changed files with 1078 additions and 9 deletions
  1. +34
    -1
      AGENTS.md
  2. +270
    -0
      app/assets/stylesheets/analytics.css
  3. +8
    -0
      app/assets/stylesheets/application.css
  4. +17
    -0
      app/controllers/admin/analytics_controller.rb
  5. +29
    -0
      app/controllers/game_controller.rb
  6. +26
    -0
      app/helpers/admin/analytics_helper.rb
  7. +2
    -0
      app/javascript/application.js
  8. +31
    -0
      app/javascript/rating_controller.js
  9. +50
    -0
      app/models/player.rb
  10. +1
    -1
      app/services/demo_activity.rb
  11. +290
    -0
      app/services/game_analytics.rb
  12. +22
    -0
      app/views/admin/analytics/_breakdown.html.erb
  13. +7
    -0
      app/views/admin/analytics/_card.html.erb
  14. +222
    -0
      app/views/admin/analytics/index.html.erb
  15. +9
    -3
      app/views/game/results.html.erb
  16. +2
    -2
      app/views/layouts/admin.html.erb
  17. +1
    -1
      app/views/layouts/application.html.erb
  18. +1
    -0
      config/importmap.rb
  19. +1
    -0
      config/locales/en.yml
  20. +4
    -0
      config/routes.rb
  21. +15
    -0
      db/migrate/20260825120000_add_analytics_to_players.rb
  22. +29
    -0
      db/migrate/20260825120001_backfill_player_furthest_step.rb
  23. +7
    -1
      db/schema.rb

+ 34
- 1
AGENTS.md View File

@ -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. - **Stage result:** Shows the outcome node and applies its score.
- **Last save / Results:** Final compost-vs-landfill choice, then the scored results screen. - **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. - **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. - **Admin Interface:** A backend for managing nodes, assets (Active Storage), users, and translations.
- **Search:** `pg_search` integration for content discovery. - **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 2 (answers under a stage):** `good_answer`, `bad_answer`, `chance`
- **Level 3 (outcomes under a chance):** `good_answer`, `bad_answer` - **Level 3 (outcomes under a chance):** `good_answer`, `bad_answer`
- `Player`: Tracks session state, `progress` (per-stage `answer_id` / `result_id`), the - `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. - `Asset` & `Attachment`: Handles media and its contextual content (body text, styling) associated with nodes.
- `User`: Admin authentication and roles. - `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 `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. 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_<n>`, `stage_<n>_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 ## Project Structure
- `app/controllers/admin/`: Admin backend logic. - `app/controllers/admin/`: Admin backend logic.
- `app/controllers/game_controller.rb`: Main game loop (stage -> answer -> result -> results). - `app/controllers/game_controller.rb`: Main game loop (stage -> answer -> result -> results).
- `app/controllers/api/`: JSON endpoints (see `docs/leaderboard_api.md`). - `app/controllers/api/`: JSON endpoints (see `docs/leaderboard_api.md`).
- `app/helpers/game_helper.rb`: Result bands and per-impact tone thresholds. - `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. - `app/models/concerns/`: Shared logic for ancestry, attachments, and tags.
- `config/locales/`: YAML translation files. - `config/locales/`: YAML translation files.
- `config/question_scores.json`: Answer scoring table. - `config/question_scores.json`: Answer scoring table.

+ 270
- 0
app/assets/stylesheets/analytics.css View File

@ -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;
}

+ 8
- 0
app/assets/stylesheets/application.css View File

@ -919,6 +919,14 @@ dialog::backdrop {
rotate: 180deg; rotate: 180deg;
} }
} }
&.is-selected {
background-color: var(--clr-black);
& svg {
fill: #fff;
}
}
} }
} }


+ 17
- 0
app/controllers/admin/analytics_controller.rb View File

@ -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

+ 29
- 0
app/controllers/game_controller.rb View File

@ -9,6 +9,7 @@ class GameController < ApplicationController
layout "application" layout "application"
before_action :set_node, only: [ :stage, :answer, :stage_result ] 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, helper_method :root_node,
:stage_index, :stage_index,
@ -116,8 +117,36 @@ class GameController < ApplicationController
end 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 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 def root_node
@root_node ||= Node.roots.viewable.first @root_node ||= Node.roots.viewable.first
end end


+ 26
- 0
app/helpers/admin/analytics_helper.rb View File

@ -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

+ 2
- 0
app/javascript/application.js View File

@ -7,6 +7,7 @@ import IntroController from "intro_controller"
import ChanceController from "chance_controller" import ChanceController from "chance_controller"
import ShareController from "share_controller" import ShareController from "share_controller"
import ModalController from "modal_controller" import ModalController from "modal_controller"
import RatingController from "rating_controller"
const application = Application.start() const application = Application.start()
@ -16,6 +17,7 @@ application.register("intro", IntroController)
application.register("chance", ChanceController) application.register("chance", ChanceController)
application.register("share", ShareController) application.register("share", ShareController)
application.register("modal", ModalController) application.register("modal", ModalController)
application.register("rating", RatingController)
// The whole game runs inside one turbo-frame so the URL never changes. Frame // 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 // navigations don't reset scroll the way a full page visit does, so each new


+ 31
- 0
app/javascript/rating_controller.js View File

@ -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 || ""
}
}

+ 50
- 0
app/models/player.rb View File

@ -7,6 +7,56 @@ class Player < ApplicationRecord
SCORE_KEYS = %w[food_waste emissions income].freeze 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 # 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 # countries, or nil if it isn't one we track. Shared by the game controller


+ 1
- 1
app/services/demo_activity.rb View File

@ -5,7 +5,7 @@
# TEMPORARY: set ENABLED = false (or delete this) and wipe the players table # TEMPORARY: set ENABLED = false (or delete this) and wipe the players table
# before the game goes live. # before the game goes live.
class DemoActivity class DemoActivity
ENABLED = true
ENABLED = false
def self.simulate! def self.simulate!
return unless ENABLED return unless ENABLED


+ 290
- 0
app/services/game_analytics.rb View File

@ -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

+ 22
- 0
app/views/admin/analytics/_breakdown.html.erb View File

@ -0,0 +1,22 @@
<section class="analytics-panel">
<h2><%= title %></h2>
<% if rows.empty? %>
<p class="analytics-empty">Nothing recorded yet.</p>
<% else %>
<div class="analytics-bars analytics-bars--compact">
<% rows.each do |row| %>
<div class="analytics-bar-row">
<div class="analytics-bar-label"><%= labeller.call(row[:key]) %></div>
<div class="analytics-bar-track">
<div class="analytics-bar-fill" style="width: <%= row[:share] %>%"></div>
</div>
<div class="analytics-bar-value">
<strong><%= number_with_delimiter(row[:count]) %></strong>
<span><%= row[:share] %>%</span>
</div>
</div>
<% end %>
</div>
<% end %>
</section>

+ 7
- 0
app/views/admin/analytics/_card.html.erb View File

@ -0,0 +1,7 @@
<div class="analytics-card <%= local_assigns[:modifier] %>">
<div class="analytics-card-value"><%= value %></div>
<div class="analytics-card-label"><%= label %></div>
<% if local_assigns[:sub].present? %>
<div class="analytics-card-sub"><%= sub %></div>
<% end %>
</div>

+ 222
- 0
app/views/admin/analytics/index.html.erb View File

@ -0,0 +1,222 @@
<%= content_for :title, "Analytics" %>
<%= turbo_frame_tag 'main' do %>
<%= turbo_stream.append 'flash', partial: 'layouts/flash' %>
<div class="list-title">
<h1><%= yield(:title) %></h1>
<div class="analytics-periods">
<% { "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 %>
</div>
</div>
<div class="analytics">
<% if DemoActivity::ENABLED %>
<p class="analytics-warning">
<strong>Demo data is on.</strong>
<code>DemoActivity</code> 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 <code>DemoActivity::ENABLED = false</code> and wipe the players table before go-live.
</p>
<% end %>
<section class="analytics-cards">
<%= 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 || "—") %>
</section>
<section class="analytics-panel">
<h2>Did you like this game?</h2>
<% ratings = @analytics.ratings %>
<% if ratings[:total].zero? %>
<p class="analytics-empty">No votes yet.</p>
<% else %>
<div class="analytics-cards">
<%= 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" %>
</div>
<div class="analytics-split">
<div class="analytics-split-part positive" style="flex-grow: <%= ratings[:up] %>"></div>
<div class="analytics-split-part negative" style="flex-grow: <%= ratings[:down] %>"></div>
</div>
<% end %>
</section>
<section class="analytics-panel">
<h2>Funnel</h2>
<p class="analytics-note">
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.
</p>
<div class="analytics-bars">
<% @analytics.funnel.each do |row| %>
<div class="analytics-bar-row">
<div class="analytics-bar-label"><%= row[:label] %></div>
<div class="analytics-bar-track">
<div class="analytics-bar-fill" style="width: <%= row[:share] %>%"></div>
</div>
<div class="analytics-bar-value">
<strong><%= number_with_delimiter(row[:reached]) %></strong>
<span><%= row[:share] %>%</span>
</div>
<div class="analytics-bar-delta">
<% if row[:lost] > 0 %>
<span class="negative">-<%= number_with_delimiter(row[:lost]) %> (<%= row[:lost_share] %>%)</span>
<% end %>
</div>
</div>
<% end %>
</div>
</section>
<section class="analytics-panel">
<h2>Where unfinished players stopped</h2>
<% if @analytics.drop_off_points.empty? %>
<p class="analytics-empty">Nobody has abandoned a game yet.</p>
<% else %>
<div class="analytics-bars">
<% max = @analytics.drop_off_points.first[:count] %>
<% @analytics.drop_off_points.each do |row| %>
<div class="analytics-bar-row">
<div class="analytics-bar-label"><%= row[:label] %></div>
<div class="analytics-bar-track">
<div class="analytics-bar-fill negative" style="width: <%= (row[:count] * 100.0 / max).round(1) %>%"></div>
</div>
<div class="analytics-bar-value"><strong><%= number_with_delimiter(row[:count]) %></strong></div>
<div class="analytics-bar-delta"></div>
</div>
<% end %>
</div>
<% end %>
</section>
<section class="analytics-panel">
<h2>Answers by stage</h2>
<% @analytics.stages.each do |stage| %>
<div class="analytics-stage">
<h3>
<span class="analytics-stage-index"><%= stage[:index] %></span>
<%= stage[:node].title %>
<small><%= number_with_delimiter(stage[:total]) %> answers</small>
</h3>
<div class="analytics-bars">
<% stage[:answers].each do |answer| %>
<div class="analytics-bar-row">
<div class="analytics-bar-label">
<%= answer[:label] %>
<%= tag.em(answer[:node].template.humanize) %>
</div>
<div class="analytics-bar-track">
<div class="analytics-bar-fill <%= answer_tone(answer[:node]) %>" style="width: <%= answer[:share] %>%"></div>
</div>
<div class="analytics-bar-value">
<strong><%= number_with_delimiter(answer[:count]) %></strong>
<span><%= answer[:share] %>%</span>
</div>
<div class="analytics-bar-delta"></div>
</div>
<% answer[:outcomes].each do |outcome| %>
<div class="analytics-bar-row is-outcome">
<div class="analytics-bar-label">
↳ <%= outcome[:label] %>
<%= tag.em(outcome[:node].template.humanize) %>
</div>
<div class="analytics-bar-track">
<div class="analytics-bar-fill <%= answer_tone(outcome[:node]) %>" style="width: <%= outcome[:share] %>%"></div>
</div>
<div class="analytics-bar-value">
<strong><%= number_with_delimiter(outcome[:count]) %></strong>
<span><%= outcome[:share] %>% of chances</span>
</div>
<div class="analytics-bar-delta"></div>
</div>
<% end %>
<% end %>
</div>
</div>
<% end %>
</section>
<% if @analytics.last_save[:node] %>
<section class="analytics-panel">
<h2>Last save <small>early exit — <%= number_with_delimiter(@analytics.last_save[:total]) %> players</small></h2>
<% if @analytics.last_save[:total].zero? %>
<p class="analytics-empty">No player has taken the early exit yet.</p>
<% else %>
<div class="analytics-bars">
<% @analytics.last_save[:answers].each do |answer| %>
<div class="analytics-bar-row">
<div class="analytics-bar-label"><%= answer[:label] %></div>
<div class="analytics-bar-track">
<div class="analytics-bar-fill" style="width: <%= answer[:share] %>%"></div>
</div>
<div class="analytics-bar-value">
<strong><%= number_with_delimiter(answer[:count]) %></strong>
<span><%= answer[:share] %>%</span>
</div>
<div class="analytics-bar-delta"></div>
</div>
<% end %>
</div>
<% end %>
</section>
<% end %>
<section class="analytics-panel">
<h2>Endings <small>finished players, excluding early exits</small></h2>
<div class="analytics-bars">
<% @analytics.result_bands.each do |band| %>
<div class="analytics-bar-row">
<div class="analytics-bar-label"><%= t("game.results.#{band[:band]}.headline", default: band[:band].to_s.humanize).html_safe %></div>
<div class="analytics-bar-track">
<div class="analytics-bar-fill <%= band_tone(band[:band]) %>" style="width: <%= band[:share] %>%"></div>
</div>
<div class="analytics-bar-value">
<strong><%= number_with_delimiter(band[:count]) %></strong>
<span><%= band[:share] %>%</span>
</div>
<div class="analytics-bar-delta"></div>
</div>
<% end %>
</div>
</section>
<div class="analytics-columns">
<%= 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) } %>
</div>
</div>
<% end %>

+ 9
- 3
app/views/game/results.html.erb View File

@ -64,13 +64,19 @@
</details> </details>
</div> </div>
<div class="like-container">
<div class="like-container" data-controller="rating" data-rating-url-value="<%= rate_path %>">
<%= tag.h3 t("game.did_you_like_this_game") %> <%= tag.h3 t("game.did_you_like_this_game") %>
<div> <div>
<button class="thumbs-up"><%= svg "ico-thumb" %></button>
<button class="thumbs-down"><%= svg "ico-thumb" %></button>
<% { up: "thumbs-up", down: "thumbs-down" }.each do |direction, css_class| %>
<button class="<%= class_names(css_class, "is-selected" => current_player.rating_direction == direction) %>"
type="button"
aria-label="<%= direction %>"
data-direction="<%= direction %>"
data-rating-target="button"
data-action="rating#rate"><%= svg "ico-thumb" %></button>
<% end %>
</div> </div>
</div> </div>


+ 2
- 2
app/views/layouts/admin.html.erb View File

@ -9,13 +9,13 @@
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<%= csp_meta_tag %> <%= csp_meta_tag %>
<%= stylesheet_link_tag "admin", "lexxy", "tom-select", "popup-menu", "forms", "lists", "assets", "nodes", "attachments" %>
<%= stylesheet_link_tag "admin", "lexxy", "tom-select", "popup-menu", "forms", "lists", "assets", "nodes", "attachments", "analytics" %>
<%= javascript_importmap_tags 'admin' %> <%= javascript_importmap_tags 'admin' %>
</head> </head>
<body> <body>
<nav id="navbar"> <nav id="navbar">
<div class="navbar-upper"> <div class="navbar-upper">
<% %i[nodes assets].each do |c| %>
<% %i[nodes assets analytics].each do |c| %>
<%= link_to url_for(controller: c, action: 'index'), <%= link_to url_for(controller: c, action: 'index'),
class: (controller_name == c.to_s ? 'navbar-link current' : 'navbar-link'), class: (controller_name == c.to_s ? 'navbar-link current' : 'navbar-link'),
data: { data: {


+ 1
- 1
app/views/layouts/application.html.erb View File

@ -17,7 +17,7 @@
<link rel="icon" sizes="192x192" href="/ikea-favicon-300x300.png"> <link rel="icon" sizes="192x192" href="/ikea-favicon-300x300.png">
<%= stylesheet_link_tag "reset", "application" %> <%= stylesheet_link_tag "reset", "application" %>
<%= frontend_javascript_importmap_tags %w[application @hotwired/turbo-rails @hotwired/stimulus language_menu_controller carousel_controller intro_controller chance_controller share_controller modal_controller] %>
<%= frontend_javascript_importmap_tags %w[application @hotwired/turbo-rails @hotwired/stimulus language_menu_controller carousel_controller intro_controller chance_controller share_controller modal_controller rating_controller] %>
</head> </head>
<body id="page"> <body id="page">
<%= turbo_frame_tag "game" do %> <%= turbo_frame_tag "game" do %>


+ 1
- 0
config/importmap.rb View File

@ -20,3 +20,4 @@ pin "intro_controller", preload: false
pin "chance_controller", preload: false pin "chance_controller", preload: false
pin "share_controller", preload: false pin "share_controller", preload: false
pin "modal_controller", preload: false pin "modal_controller", preload: false
pin "rating_controller", preload: false

+ 1
- 0
config/locales/en.yml View File

@ -390,6 +390,7 @@ en:
icons: icons:
assets: image assets: image
analytics: bar_chart
users: person users: person
nodes: file_copy nodes: file_copy
tags: sell tags: sell


+ 4
- 0
config/routes.rb View File

@ -34,6 +34,9 @@ Rails.application.routes.draw do
# Users # Users
resources :users resources :users
# Analytics
get "analytics", to: "analytics#index"
# Root # Root
root to: "nodes#index" root to: "nodes#index"
end end
@ -75,6 +78,7 @@ Rails.application.routes.draw do
get "done", to: "game#done" get "done", to: "game#done"
get "results", to: "game#results" get "results", to: "game#results"
post "results/rating", to: "game#rate", as: :rate
get "", to: "game#index", as: :locale_root get "", to: "game#index", as: :locale_root
end end


+ 15
- 0
db/migrate/20260825120000_add_analytics_to_players.rb View File

@ -0,0 +1,15 @@
class AddAnalyticsToPlayers < ActiveRecord::Migration[8.1]
def change
# Furthest screen the player reached, so drop-off can be measured for
# people who quit before ever answering anything (progress stays empty).
add_column :players, :furthest_step, :string
# Thumbs up (1) / thumbs down (-1) from the results screen. One per player.
add_column :players, :rating, :integer
add_column :players, :rated_at, :datetime
add_index :players, :furthest_step
add_index :players, :rating
add_index :players, :created_at
end
end

+ 29
- 0
db/migrate/20260825120001_backfill_player_furthest_step.rb View File

@ -0,0 +1,29 @@
# Existing players predate step tracking, so the funnel would start empty.
# Their `progress` still says which stages they answered -- good enough to
# reconstruct roughly how far they got. Players with no progress at all are
# left alone: there is nothing to infer from.
class BackfillPlayerFurthestStep < ActiveRecord::Migration[8.1]
def up
Player.where(furthest_step: nil).find_each do |player|
stages = player.progress.keys.grep(/\A\d+\z/).map(&:to_i)
next if stages.empty? && !player.progress.key?(Player::LAST_SAVE_KEY)
# is_done is set on the final screen, not the results screen, so stop
# there rather than claiming they saw the results.
step =
if player.is_done?
"done"
elsif player.progress.key?(Player::LAST_SAVE_KEY)
"last_save"
else
"stage_#{stages.max}_result"
end
player.update_column(:furthest_step, step)
end
end
def down
# No-op: the reconstructed values are indistinguishable from live ones.
end
end

+ 7
- 1
db/schema.rb View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_07_03_000000) do
ActiveRecord::Schema[8.1].define(version: 2026_08_25_120001) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql" enable_extension "pg_catalog.plpgsql"
@ -117,15 +117,21 @@ ActiveRecord::Schema[8.1].define(version: 2026_07_03_000000) do
create_table "players", force: :cascade do |t| create_table "players", force: :cascade do |t|
t.string "country" t.string "country"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.string "furthest_step"
t.boolean "is_done", default: false, null: false t.boolean "is_done", default: false, null: false
t.string "locale", null: false t.string "locale", null: false
t.jsonb "progress", default: {}, null: false t.jsonb "progress", default: {}, null: false
t.datetime "rated_at"
t.integer "rating"
t.integer "score", default: 0, null: false t.integer "score", default: 0, null: false
t.jsonb "scores", default: {}, null: false t.jsonb "scores", default: {}, null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["country"], name: "index_players_on_country" t.index ["country"], name: "index_players_on_country"
t.index ["created_at"], name: "index_players_on_created_at"
t.index ["furthest_step"], name: "index_players_on_furthest_step"
t.index ["is_done"], name: "index_players_on_is_done" t.index ["is_done"], name: "index_players_on_is_done"
t.index ["locale"], name: "index_players_on_locale" t.index ["locale"], name: "index_players_on_locale"
t.index ["rating"], name: "index_players_on_rating"
end end
create_table "quiz_results", force: :cascade do |t| create_table "quiz_results", force: :cascade do |t|


Loading…
Cancel
Save