From 8fc1fca6efd7183351b904a77aa5d057af90c735 Mon Sep 17 00:00:00 2001 From: Mattias Bodlund Date: Wed, 26 Aug 2026 10:31:38 +0200 Subject: [PATCH] Analytics Device --- AGENTS.md | 27 +++++++++++--- Gemfile.lock | 4 +-- app/controllers/game_controller.rb | 4 ++- app/helpers/admin/analytics_helper.rb | 8 +++++ app/models/player.rb | 36 +++++++++++++++++++ app/services/game_analytics.rb | 10 ++++++ app/views/admin/analytics/index.html.erb | 2 ++ .../20260826100000_add_device_to_players.rb | 10 ++++++ db/schema.rb | 4 ++- 9 files changed, 97 insertions(+), 8 deletions(-) create mode 100644 db/migrate/20260826100000_add_device_to_players.rb diff --git a/AGENTS.md b/AGENTS.md index b1e0c18..4e1c960 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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_`, `stage__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 diff --git a/Gemfile.lock b/Gemfile.lock index 6d2c4de..0555078 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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 diff --git a/app/controllers/game_controller.rb b/app/controllers/game_controller.rb index 79248c2..0ae2776 100644 --- a/app/controllers/game_controller.rb +++ b/app/controllers/game_controller.rb @@ -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 diff --git a/app/helpers/admin/analytics_helper.rb b/app/helpers/admin/analytics_helper.rb index 7806954..9e8b5a4 100644 --- a/app/helpers/admin/analytics_helper.rb +++ b/app/helpers/admin/analytics_helper.rb @@ -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? diff --git a/app/models/player.rb b/app/models/player.rb index d7114cf..534a372 100644 --- a/app/models/player.rb +++ b/app/models/player.rb @@ -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). diff --git a/app/services/game_analytics.rb b/app/services/game_analytics.rb index f990f42..b716aa6 100644 --- a/app/services/game_analytics.rb +++ b/app/services/game_analytics.rb @@ -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 diff --git a/app/views/admin/analytics/index.html.erb b/app/views/admin/analytics/index.html.erb index 2da6720..a7ad72f 100644 --- a/app/views/admin/analytics/index.html.erb +++ b/app/views/admin/analytics/index.html.erb @@ -213,6 +213,8 @@
+ <%= 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, diff --git a/db/migrate/20260826100000_add_device_to_players.rb b/db/migrate/20260826100000_add_device_to_players.rb new file mode 100644 index 0000000..2bc472b --- /dev/null +++ b/db/migrate/20260826100000_add_device_to_players.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 51d4018..3f745cc 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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"