| @ -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; | |||||
| } | |||||
| @ -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 | |||||
| @ -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 | |||||
| @ -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 || "" | |||||
| } | |||||
| } | |||||
| @ -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 | |||||
| @ -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> | |||||
| @ -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> | |||||
| @ -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 %> | |||||
| @ -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 | |||||
| @ -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 | |||||