Browse Source

Analytics Device

main
Mattias Bodlund 3 weeks ago
parent
commit
8fc1fca6ef
9 changed files with 97 additions and 8 deletions
  1. +23
    -4
      AGENTS.md
  2. +2
    -2
      Gemfile.lock
  3. +3
    -1
      app/controllers/game_controller.rb
  4. +8
    -0
      app/helpers/admin/analytics_helper.rb
  5. +36
    -0
      app/models/player.rb
  6. +10
    -0
      app/services/game_analytics.rb
  7. +2
    -0
      app/views/admin/analytics/index.html.erb
  8. +10
    -0
      db/migrate/20260826100000_add_device_to_players.rb
  9. +3
    -1
      db/schema.rb

+ 23
- 4
AGENTS.md View File

@ -14,7 +14,8 @@ This project is a Ruby on Rails application developed for the IKEA Foundation. I
- **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.
answer distribution, the results-screen thumbs vote and who played — device, language and
country. See below.
- **Admin Interface:** A backend for managing nodes, assets (Active Storage), users, and translations.
- **Search:** `pg_search` integration for content discovery.
@ -35,8 +36,8 @@ This project is a Ruby on Rails application developed for the IKEA Foundation. I
- **Level 3 (outcomes under a chance):** `good_answer`, `bad_answer`
- `Player`: Tracks session state, `progress` (per-stage `answer_id` / `result_id`), the
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`).
the furthest screen reached (`furthest_step`), the `device` class they played on 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.
@ -84,12 +85,14 @@ 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`:
There is no event log. `GameAnalytics` derives everything from 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.
- `device` — what they played *on*: `mobile`, `tablet` or `desktop`.
- `locale` and `country` — who they are, feeding the two breakdown panels beside devices.
Step names are `facts`, `intro`, `stage_<n>`, `stage_<n>_result`, `last_save`, `done`,
`results`, ordered by `Player.step_rank`.
@ -103,6 +106,22 @@ reported as its own panel rather than as a funnel step.
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.
### Device
`Player.device_from_user_agent` classifies the User-Agent once, when `GameController#start`
creates the player; there is no client-side probe and nothing re-checks it later. The match
order matters — tablets are tested first, because an Android tablet's UA also says "Android"
and only phones add a `Mobi` token, so a mobile-first test would swallow every tablet.
Crawlers and anything unrecognisable are stored as `nil` rather than falling through to
`desktop`, and `GameAnalytics#by_device` drops `nil` rows — so the shares describe only the
players we could actually place, and a bot run can't quietly pad the desktop column.
**Known blind spot:** iPadOS 13+ sends a desktop Safari UA by default, so some iPads are
counted as desktop. Nothing short of client-side probing fixes it; the tablet share is a
floor, not an exact figure. Players created before this column existed are `nil` forever —
the UA was never stored, so there is nothing to backfill from.
### Thumbs up / down
`Player#rate!` stores `rating` as `1` / `-1` (`Player::RATINGS`), one per player — voting


+ 2
- 2
Gemfile.lock View File

@ -185,7 +185,7 @@ GEM
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.2)
net-protocol (0.3.0)
timeout
net-smtp (0.5.1)
net-protocol
@ -484,7 +484,7 @@ CHECKSUMS
msgpack (1.8.4) sha256=4411c22d350dd1c20250f7eada3cca2695438c2f769cf0782f0cd065d90a3e7b
net-imap (0.6.6) sha256=96aa4ee50df3060203e649efc341f53480b791d49e150f2fdebf68beb141a8df
net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3
net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8
net-protocol (0.3.0) sha256=ba310c3d4f1cad46bb1ab20336b06669b1ff8f7c568d9cb9342b32a718547472
net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736
nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f


+ 3
- 1
app/controllers/game_controller.rb View File

@ -25,7 +25,9 @@ class GameController < ApplicationController
# POST
def start
player = Player.create(locale: I18n.locale.to_s, country: country_from_param)
player = Player.create(locale: I18n.locale.to_s,
country: country_from_param,
device: Player.device_from_user_agent(request.user_agent))
ResolvePlayerCountryJob.perform_later(player.id, request.remote_ip) if player.country.blank?
session[:player_id] = player.id


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

@ -37,6 +37,14 @@ module Admin::AnalyticsHelper
end
# Labels a row of the Devices breakdown. The stored values are already the
# words we want, so this is just capitalisation -- it exists so the panel
# matches the other two breakdowns rather than printing a raw column value.
def device_breakdown_label(key)
key.to_s.capitalize
end
def format_duration(seconds)
return "" if seconds.blank? || seconds.to_i.zero?


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

@ -58,6 +58,42 @@ class Player < ApplicationRecord
# ---------------------------------------------------------------- device
DEVICES = %w[mobile tablet desktop].freeze
# Tablets first: every tablet UA also carries a token that would read as a
# phone (Android tablets say "Android", the iPad used to say "like Mac OS X").
TABLET_PATTERN = /ipad|tablet|kindle|silk|playbook|android(?!.*mobi)/i
MOBILE_PATTERN = /mobi|iphone|ipod|phone|blackberry|iemobile|opera mini/i
# Crawlers advertise a browser engine, so they have to be turned away before
# the desktop catch-all claims them.
BOT_PATTERN = /bot|crawl|spider|slurp|preview|headless|monitor|curl|wget|python|ruby|http/i
scope :by_device, ->(device) { where(device: device) }
# Coarse device class from a User-Agent string. Deliberately crude -- we want
# the mobile/tablet/desktop split, not a device database. Anything we cannot
# place is nil rather than being dumped into "desktop", so the breakdown never
# pretends bots and oddities were people on laptops.
#
# Known blind spot: iPadOS 13+ sends a desktop Safari UA by default, so some
# iPads land in "desktop". Nothing short of client-side probing fixes that.
def self.device_from_user_agent(user_agent)
ua = user_agent.to_s
return nil if ua.blank?
return nil if ua.match?(BOT_PATTERN)
case ua
when TABLET_PATTERN then "tablet"
when MOBILE_PATTERN then "mobile"
when /mozilla|webkit|gecko|opera/i then "desktop"
end
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
# (?country= override) and ResolvePlayerCountryJob (IP geolocation).


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

@ -215,6 +215,16 @@ class GameAnalytics
end
# Mobile / tablet / desktop, from the User-Agent at the moment the player
# started. Players we could not classify (and everyone from before device
# tracking) are dropped rather than counted, so the shares describe the
# players we actually know about.
def by_device
@by_device ||= breakdown(players.where.not(device: nil).group(:device).count)
.sort_by { |row| Player::DEVICES.index(row[:key]) || Player::DEVICES.size }
end
# 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


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

@ -213,6 +213,8 @@
<div class="analytics-columns">
<%= render "breakdown", title: "Devices", rows: @analytics.by_device,
labeller: ->(key) { device_breakdown_label(key) } %>
<%= render "breakdown", title: "Languages", rows: @analytics.by_locale,
labeller: ->(key) { language_breakdown_label(key) } %>
<%= render "breakdown", title: "Countries", rows: @analytics.by_country,


+ 10
- 0
db/migrate/20260826100000_add_device_to_players.rb View File

@ -0,0 +1,10 @@
class AddDeviceToPlayers < ActiveRecord::Migration[8.1]
def change
# Coarse device class -- "mobile", "tablet" or "desktop" -- classified from
# the User-Agent when the player starts. Nil for players created before this
# existed, and for anything we cannot place.
add_column :players, :device, :string
add_index :players, :device
end
end

+ 3
- 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_08_25_120001) do
ActiveRecord::Schema[8.1].define(version: 2026_08_26_100000) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@ -117,6 +117,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_25_120001) do
create_table "players", force: :cascade do |t|
t.string "country"
t.datetime "created_at", null: false
t.string "device"
t.string "furthest_step"
t.boolean "is_done", default: false, null: false
t.string "locale", null: false
@ -128,6 +129,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_25_120001) do
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 ["device"], name: "index_players_on_device"
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"


Loading…
Cancel
Save