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.
- **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_<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
- `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.

+ 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;
}
}
&.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"
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


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


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


+ 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
# before the game goes live.
class DemoActivity
ENABLED = true
ENABLED = false
def self.simulate!
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>
</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") %>
<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>


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

@ -9,13 +9,13 @@
<%= csrf_meta_tags %>
<%= 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' %>
</head>
<body>
<nav id="navbar">
<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'),
class: (controller_name == c.to_s ? 'navbar-link current' : 'navbar-link'),
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">
<%= 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>
<body id="page">
<%= 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 "share_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:
assets: image
analytics: bar_chart
users: person
nodes: file_copy
tags: sell


+ 4
- 0
config/routes.rb View File

@ -34,6 +34,9 @@ Rails.application.routes.draw do
# Users
resources :users
# Analytics
get "analytics", to: "analytics#index"
# Root
root to: "nodes#index"
end
@ -75,6 +78,7 @@ Rails.application.routes.draw do
get "done", to: "game#done"
get "results", to: "game#results"
post "results/rating", to: "game#rate", as: :rate
get "", to: "game#index", as: :locale_root
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.
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
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|
t.string "country"
t.datetime "created_at", null: false
t.string "furthest_step"
t.boolean "is_done", default: false, null: false
t.string "locale", null: false
t.jsonb "progress", default: {}, null: false
t.datetime "rated_at"
t.integer "rating"
t.integer "score", default: 0, null: false
t.jsonb "scores", default: {}, null: false
t.datetime "updated_at", null: false
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 ["locale"], name: "index_players_on_locale"
t.index ["rating"], name: "index_players_on_rating"
end
create_table "quiz_results", force: :cascade do |t|


Loading…
Cancel
Save