diff --git a/AGENTS.md b/AGENTS.md index 0585faf..e4a322f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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_`, `stage__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 diff --git a/Gemfile b/Gemfile index cb26047..20c1491 100644 --- a/Gemfile +++ b/Gemfile @@ -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" diff --git a/app/controllers/concerns/utm_tracking.rb b/app/controllers/concerns/utm_tracking.rb new file mode 100644 index 0000000..c6c706d --- /dev/null +++ b/app/controllers/concerns/utm_tracking.rb @@ -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 diff --git a/app/controllers/game_controller.rb b/app/controllers/game_controller.rb index 230887c..c14ec85 100644 --- a/app/controllers/game_controller.rb +++ b/app/controllers/game_controller.rb @@ -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 diff --git a/app/controllers/languages_controller.rb b/app/controllers/languages_controller.rb index 96d6caa..3e8d02c 100644 --- a/app/controllers/languages_controller.rb +++ b/app/controllers/languages_controller.rb @@ -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 diff --git a/app/helpers/admin/analytics_helper.rb b/app/helpers/admin/analytics_helper.rb index 9e8b5a4..3e33769 100644 --- a/app/helpers/admin/analytics_helper.rb +++ b/app/helpers/admin/analytics_helper.rb @@ -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? diff --git a/app/models/player.rb b/app/models/player.rb index 534a372..b835b6a 100644 --- a/app/models/player.rb +++ b/app/models/player.rb @@ -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. diff --git a/app/services/game_analytics.rb b/app/services/game_analytics.rb index b716aa6..24bb659 100644 --- a/app/services/game_analytics.rb +++ b/app/services/game_analytics.rb @@ -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 diff --git a/app/views/admin/analytics/_breakdown.html.erb b/app/views/admin/analytics/_breakdown.html.erb index 3a98ac6..e041d2d 100644 --- a/app/views/admin/analytics/_breakdown.html.erb +++ b/app/views/admin/analytics/_breakdown.html.erb @@ -1,6 +1,10 @@

<%= title %>

+ <% if local_assigns[:note].present? %> +

<%= note %>

+ <% end %> + <% if rows.empty? %>

Nothing recorded yet.

<% else %> diff --git a/app/views/admin/analytics/index.html.erb b/app/views/admin/analytics/index.html.erb index a7ad72f..703d036 100644 --- a/app/views/admin/analytics/index.html.erb +++ b/app/views/admin/analytics/index.html.erb @@ -212,6 +212,14 @@
+ <%= 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) } %> + +
<%= render "breakdown", title: "Devices", rows: @analytics.by_device, labeller: ->(key) { device_breakdown_label(key) } %> diff --git a/db/migrate/20260907120000_add_utm_to_players.rb b/db/migrate/20260907120000_add_utm_to_players.rb new file mode 100644 index 0000000..9b2e39f --- /dev/null +++ b/db/migrate/20260907120000_add_utm_to_players.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 3f745cc..996c54b 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_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|