Mattias Bodlund 1 week ago
parent
commit
86693e4e83
12 changed files with 153 additions and 5 deletions
  1. +26
    -0
      AGENTS.md
  2. +1
    -1
      Gemfile
  3. +44
    -0
      app/controllers/concerns/utm_tracking.rb
  4. +5
    -3
      app/controllers/game_controller.rb
  5. +4
    -0
      app/controllers/languages_controller.rb
  6. +10
    -0
      app/helpers/admin/analytics_helper.rb
  7. +6
    -0
      app/models/player.rb
  8. +23
    -0
      app/services/game_analytics.rb
  9. +4
    -0
      app/views/admin/analytics/_breakdown.html.erb
  10. +8
    -0
      app/views/admin/analytics/index.html.erb
  11. +15
    -0
      db/migrate/20260907120000_add_utm_to_players.rb
  12. +7
    -1
      db/schema.rb

+ 26
- 0
AGENTS.md View File

@ -96,6 +96,7 @@ There is no event log. `GameAnalytics` derives everything from columns on `playe
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.
- `utm_source` / `utm_medium` / `utm_campaign` / `utm_content` — how they got here. See below.
Step names are `facts`, `intro`, `stage_<n>`, `stage_<n>_result`, `last_save`, `done`,
`results`, ordered by `Player.step_rank`.
@ -125,6 +126,31 @@ counted as desktop. Nothing short of client-side probing fixes it; the tablet sh
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.
### Traffic source (QR codes)
The printed material — table talker, coffee machine banner, canteen poster, toilet talker,
booth poster — each carries a QR code to the same game, differing only in `utm_content`:
https://savethetomato.ikeafoundation.org/?utm_source=physical&utm_medium=qr
&utm_campaign=ikea_foundation_week_2026&utm_content=table_talker
`UtmTracking` (`app/controllers/concerns/utm_tracking.rb`) is included in `LanguagesController`
and `GameController`. It parks the four tags in the session, and `GameController#start` writes
them onto the player row. The session hop is not optional: the QR codes point at the bare
domain, `LanguagesController#index` redirects to `/:locale` and drops the query string, and the
player does not exist until the language page posts to `game#start`.
Last touch wins — a second scan is a second visit, and the player created right after it
belongs to the code that was actually scanned. Values are trimmed to 100 characters, and only
the four known keys are ever read, so the query string cannot write anything else.
`GameAnalytics#by_source` groups on `COALESCE(utm_content, utm_source, 'direct')``utm_content`
is the only tag that differs between the codes, and untagged players all land in one bucket that
is pinned to the bottom of the panel so the QR rows stay together at the top.
**Not counted here:** the board game QR points at `dice.ikeafoundation.org`, a different app.
Its `utm_content=board_game` scans are recorded there, not in this dashboard.
### Thumbs up / down
`Player#rate!` stores `rating` as `1` / `-1` (`Player::RATINGS`), one per player — voting


+ 1
- 1
Gemfile View File

@ -27,7 +27,7 @@ gem "sidekiq"
gem "redis", ">= 4.0.1"
# Build JSON APIs with ease [https://github.com/rails/jbuilder]
gem "json", "2.21.2"
gem "json", "< 3"
gem "jbuilder"


+ 44
- 0
app/controllers/concerns/utm_tracking.rb View File

@ -0,0 +1,44 @@
# Remembers the campaign parameters a player arrived with, so the printed QR
# codes can be told apart in the admin dashboard.
#
# The QR codes point at the bare domain, which redirects to /:locale and drops
# the query string -- and the player row is not created until the language page
# posts to game#start. The tags are therefore parked in the session on the way
# in and read back out when the player is created.
#
# Last touch wins: a second scan is a second visit, and the player row that
# follows it belongs to the code that was actually scanned.
module UtmTracking
extend ActiveSupport::Concern
SESSION_KEY = "utm".freeze
included do
before_action :capture_utm
end
private
def capture_utm
tags = Player::UTM_KEYS.index_with { |key| clean_utm(params[key]) }.compact
return if tags.empty?
# String keys: the session round-trips through the cookie, which has no
# symbols, so `utm_attributes` would stop matching after the redirect.
session[SESSION_KEY] = tags.transform_keys(&:to_s)
end
# Query parameters are typed by whoever holds the link, so they are trimmed
# to something a column and a dashboard row can live with.
def clean_utm(value)
value.to_s.strip.first(100).presence
end
def utm_attributes
(session[SESSION_KEY] || {}).slice(*Player::UTM_KEYS.map(&:to_s))
end
end

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

@ -1,5 +1,6 @@
class GameController < ApplicationController
include QuizHelperMethods
include UtmTracking
include DevJump
skip_before_action :require_player!, only: [ :index, :start ]
@ -26,9 +27,10 @@ class GameController < ApplicationController
# POST
def start
player = Player.create(locale: I18n.locale.to_s,
country: country_from_param,
device: Player.device_from_user_agent(request.user_agent))
player = Player.create(utm_attributes.merge(
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


+ 4
- 0
app/controllers/languages_controller.rb View File

@ -1,4 +1,8 @@
class LanguagesController < ApplicationController
# The QR codes point at the bare domain, so this is where the campaign tags
# arrive -- the redirect below throws the query string away.
include UtmTracking
def index
redirect_to locale_root_path(locale: first_matching_language)
end


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

@ -45,6 +45,16 @@ module Admin::AnalyticsHelper
end
# Labels a row of the traffic-source breakdown. The stored value is the raw
# `utm_content` slug from the QR code ("table_talker"), which is readable
# enough once the underscores are gone.
def source_breakdown_label(key)
return "Direct / untagged" if key.to_s == Player::DIRECT_SOURCE_KEY
key.to_s.tr("_", " ").upcase_first
end
def format_duration(seconds)
return "" if seconds.blank? || seconds.to_i.zero?


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

@ -9,6 +9,12 @@ class Player < ApplicationRecord
RATINGS = { up: 1, down: -1 }.freeze
# Campaign tags carried in from the printed QR codes. See UtmTracking.
UTM_KEYS = %i[utm_source utm_medium utm_campaign utm_content].freeze
# Bucket for players who arrived without any campaign tags at all.
DIRECT_SOURCE_KEY = "direct".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.


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

@ -225,6 +225,29 @@ class GameAnalytics
end
# How players got here. The campaign is printed on QR codes that differ only
# in `utm_content`, so that is the key -- `utm_source` is the fallback for a
# tagged link that names no specific placement, and everything untagged lands
# in one "direct" bucket.
#
# The board game QR points at dice.ikeafoundation.org, a different app, so it
# never shows up here.
def by_source
@by_source ||= begin
key = Arel.sql(
"COALESCE(NULLIF(utm_content, ''), NULLIF(utm_source, ''), '#{Player::DIRECT_SOURCE_KEY}')"
)
rows = breakdown(players.group(key).count)
# `breakdown` sorts by size and "direct" is usually the biggest row of
# all, which would push the QR codes -- the thing this panel is for --
# below the fold. Pinned to the bottom instead, like "Other" countries.
rows.reject { |row| row[:key] == Player::DIRECT_SOURCE_KEY } +
rows.select { |row| row[:key] == Player::DIRECT_SOURCE_KEY }
end
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


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

@ -1,6 +1,10 @@
<section class="analytics-panel">
<h2><%= title %></h2>
<% if local_assigns[:note].present? %>
<p class="analytics-note"><%= note %></p>
<% end %>
<% if rows.empty? %>
<p class="analytics-empty">Nothing recorded yet.</p>
<% else %>


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

@ -212,6 +212,14 @@
</section>
<%= render "breakdown", title: "How they got here",
note: "Where the scan came from. Each printed QR code carries its own " \
"utm_content tag, so the rows below are the physical placements. " \
"The board game QR points at dice.ikeafoundation.org and is counted there, not here.",
rows: @analytics.by_source,
labeller: ->(key) { source_breakdown_label(key) } %>
<div class="analytics-columns">
<%= render "breakdown", title: "Devices", rows: @analytics.by_device,
labeller: ->(key) { device_breakdown_label(key) } %>


+ 15
- 0
db/migrate/20260907120000_add_utm_to_players.rb View File

@ -0,0 +1,15 @@
class AddUtmToPlayers < ActiveRecord::Migration[8.1]
def change
# Where the player came from. The campaign is run on printed QR codes, so
# `utm_content` is the interesting one -- it names the physical thing that
# was scanned (table_talker, canteen_poster, ...). The other three are kept
# so a non-QR link (mail, intranet) can still be told apart later.
add_column :players, :utm_source, :string
add_column :players, :utm_medium, :string
add_column :players, :utm_campaign, :string
add_column :players, :utm_content, :string
add_index :players, :utm_content
add_index :players, :utm_source
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_08_26_100000) do
ActiveRecord::Schema[8.1].define(version: 2026_09_07_120000) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@ -127,6 +127,10 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_26_100000) do
t.integer "score", default: 0, null: false
t.jsonb "scores", default: {}, null: false
t.datetime "updated_at", null: false
t.string "utm_campaign"
t.string "utm_content"
t.string "utm_medium"
t.string "utm_source"
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"
@ -134,6 +138,8 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_26_100000) do
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"
t.index ["utm_content"], name: "index_players_on_utm_content"
t.index ["utm_source"], name: "index_players_on_utm_source"
end
create_table "quiz_results", force: :cascade do |t|


Loading…
Cancel
Save