Mattias Bodlund 3 weeks ago
parent
commit
6e898a9365
5 changed files with 106 additions and 54 deletions
  1. +56
    -50
      app/assets/stylesheets/application.css
  2. +21
    -0
      app/helpers/admin/analytics_helper.rb
  3. +25
    -1
      app/services/game_analytics.rb
  4. +3
    -2
      app/views/admin/analytics/index.html.erb
  5. +1
    -1
      app/views/game/results.html.erb

+ 56
- 50
app/assets/stylesheets/application.css View File

@ -86,7 +86,7 @@ h2@property --glow-deg {
--clr-sand-light: #F7F3EB;
--ff-ikea: "Noto IKEA", "Noto Sans", system-ui, sans-serif;
--fs-s: 0.75rem;
--fs-m: 0.875rem;
--fs-base: 1rem;
@ -102,6 +102,10 @@ h2@property --glow-deg {
--td-2xl: 700 3rem/1.2 var(--ff-ikea);
--td-3xl: 700 4rem/1.2 var(--ff-ikea);
--ease-out: cubic-bezier(0.22, 0.61, 0.36, 1);
--dur-base: 600ms;
--dur-slow: 900ms;
font: 16px/1.4 var(--ff-ikea);
}
@ -110,8 +114,6 @@ h2@property --glow-deg {
background-color: #0058a3;
}
/* The whole game lives in a single turbo-frame so the URL never changes.
`contents` keeps the frame out of the layout: <main> stays a flex child of <body>. */
turbo-frame#game {
display: contents;
}
@ -133,8 +135,6 @@ body {
}
}
button {
cursor: pointer;
}
@ -238,7 +238,7 @@ header {
&.current {
font-weight: 700;
}
&::after {
content: "";
position: absolute;
@ -259,7 +259,7 @@ main, .modal-panel {
flex-direction: column;
min-height: 100vh;
min-height: 100svh;
& article {
flex: 0 0 auto;
background-color: var(--clr-bg);
@ -267,7 +267,6 @@ main, .modal-panel {
text-align: center;
padding: 1.5rem 1.5rem 1rem 1.5rem;
& p {
max-width: 32ch;
margin: 0 auto 1.5rem auto;
@ -283,7 +282,6 @@ main, .modal-panel {
font-weight: 400;
margin: 0 auto 0.5rem auto;
}
}
.start {
@ -291,12 +289,51 @@ main, .modal-panel {
--clr-action: var(--clr-green);
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes header-drop-in {
from {
transform: translateY(calc(-100% - 1rem));
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes wave-slide-in {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
@media (max-width: 1023.98px) and (prefers-reduced-motion: no-preference) {
.start .hero-container {
overflow: clip;
}
.start .hero-container img,
.start > article {
animation: fade-in var(--dur-slow) var(--ease-out) both;
}
body:has(.start) header {
animation: header-drop-in var(--dur-base) var(--ease-out) 200ms both;
}
.start .hero-container svg.ico-wave {
animation: wave-slide-in var(--dur-base) var(--ease-out) calc(var(--dur-slow) * 0.4) both;
}
}
.facts {
--clr-bg: var(--clr-green);
--clr-action: var(--clr-white);
}
/* Facts modal — native <dialog>. Fullscreen sheet on mobile, card on desktop. */
.facts-modal {
padding: 0;
border: none;
@ -339,7 +376,6 @@ main, .modal-panel {
}
}
.good_answer {
& .hero-container {
background-color: var(--clr-green);
@ -354,7 +390,6 @@ main, .modal-panel {
.good_answer,
.bad_answer {
--clr-bg: var(--clr-sand-light);
--clr-action: var(--clr-sand-light);
@ -419,7 +454,6 @@ main, .modal-panel {
}
}
.hero-container, .carousel {
flex: 1 1 0;
min-height: 0;
@ -429,7 +463,7 @@ main, .modal-panel {
display: grid;
grid-template: 1fr / 1fr;
position: relative;
& img, video {
grid-area: 1 / 1;
width: 100%;
@ -448,14 +482,13 @@ main, .modal-panel {
&:has(video) {
color: var(--clr-white);
& h2 {
text-align: left;
margin-top: 1em;
}
}
svg.ico-wave {
grid-area: 1 / 1;
place-self: end end;
@ -478,8 +511,6 @@ main, .modal-panel {
}
}
& .hero-link {
grid-area: 1 / 1;
z-index: 2;
@ -517,8 +548,6 @@ form:has(.cta) {
text-decoration: none;
}
.arrow {
display: block;
background-image: url("ico-arrow-right.svg");
@ -560,7 +589,6 @@ form:has(.cta) {
margin-block-start: 0.75rem;
}
.carousel {
display: grid;
grid-auto-flow: column;
@ -569,14 +597,13 @@ form:has(.cta) {
scroll-behavior: smooth;
scroll-snap-type: x mandatory;
scrollbar-width: none;
> li {
scroll-snap-align: center;
display: flex;
flex-direction: column;
--clr-bg: var(--clr-green);
}
}
@ -741,7 +768,7 @@ dialog::backdrop {
display: flex;
flex-direction: column;
gap: 2rem;
svg.ico-wave {
align-self: center;
transform: translateY(1px);
@ -782,8 +809,6 @@ dialog::backdrop {
}
}
.results-tagline {
display: grid;
grid-template-columns: 1fr max-content;
@ -811,7 +836,7 @@ dialog::backdrop {
& + .impact {
margin-block-start: 0;
}
h3 {
font: var(--td-base);
font-weight: 700;
@ -888,7 +913,6 @@ dialog::backdrop {
display: none;
}
}
}
.like-container {
@ -959,10 +983,7 @@ dialog::backdrop {
}
}
@media (min-width: 1024px) {
header {
inset: 1.5625rem 1.75rem auto 1.75rem;
box-shadow: 0px 4px 8px 0px #0000000F, 0px 0px 4px 0px #0000000A;
@ -979,9 +1000,7 @@ dialog::backdrop {
article h1, .results-header h1 {
font: var(--td-2xl);
font-weight: 400;
}
}
.modal-panel {
@ -989,19 +1008,15 @@ dialog::backdrop {
--clr-bg: var(--clr-white);
--clr-action: var(--clr-green);
/* Content-sized (not flex-grow) so the card can be max-content tall. */
.hero-container,
.carousel {
flex: 0 0 auto;
}
/* Definite height so the picture/img fill it and the card sizes to content. */
.hero-container {
height: min(30vh, 320px);
}
/* Overlap the bottom edge so no subpixel sliver of the image shows
beneath the wave on fractional (retina) device-pixel offsets. */
.hero-container svg.ico-wave {
transform: translateY(1px);
}
@ -1096,7 +1111,6 @@ dialog::backdrop {
}
&.intro {
h2 {
font-size: var(--fs-2xl);
}
@ -1116,7 +1130,7 @@ dialog::backdrop {
flex-direction: column;
padding: 2.5rem;
text-align: left;
h1, p, .cta {
margin-left: 0;
margin-right: 0;
@ -1154,17 +1168,16 @@ dialog::backdrop {
transform-origin: 0 0;
transform: rotate(90deg) translate(345px, -174px) scaleX(-1);
}
}
}
&.stage, &.good_answer, &.bad_answer, &.last_save {
grid-template-columns: 6fr 4fr;
grid-template-rows: auto 1fr auto;
.hero-container {
grid-area: 1 / 1 / span 3 / span 1;
article {
display: none;
}
@ -1182,7 +1195,6 @@ dialog::backdrop {
transform-origin: 0 0;
transform: rotate(-90deg) translate(-345px, 174px);
}
}
.stage-header-container {
@ -1261,7 +1273,6 @@ dialog::backdrop {
.hero-container svg.ico-wave {
fill: var(--clr-green);
}
}
&.bad_answer {
.stage-header-container, .question-container, .answers-container {
@ -1304,7 +1315,7 @@ dialog::backdrop {
.results-tagline {
display: block;
svg {
display: none;
}
@ -1337,13 +1348,10 @@ dialog::backdrop {
--clr-action: var(--clr-sand-light);
}
}
}
.like-container {
button {
&.is-selected {
background-color: var(--clr-white);
border-color: var(--clr-white);
@ -1354,10 +1362,8 @@ dialog::backdrop {
}
}
}
}
.debug-score {
display: none;
position: fixed;


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

@ -16,6 +16,27 @@ module Admin::AnalyticsHelper
end
# Labels a row of the Countries breakdown. Everything outside the headcount
# list is collapsed into one bucket by GameAnalytics#by_country, and that
# bucket has no entry under `countries:` -- so name it here rather than
# letting the default fall through and print "OTHER".
def country_breakdown_label(key)
return "Other" if key.to_s == GameAnalytics::OTHER_COUNTRY_KEY
t("countries.#{key}", default: key.to_s.upcase)
end
# Labels a row of the Languages breakdown. `players.locale` stores the raw
# locale code, which is not much use at a glance -- so name it. Uses
# `language_names` (English exonyms: "Danish", "Chinese") rather than
# `languages` (native names: "Dansk", "\u4e2d\u6587"), which is what the player-facing
# menu shows; this dashboard reads better in one language.
def language_breakdown_label(key)
t("language_names.#{key}", default: key.to_s)
end
def format_duration(seconds)
return "" if seconds.blank? || seconds.to_i.zero?


+ 25
- 1
app/services/game_analytics.rb View File

@ -22,6 +22,9 @@ class GameAnalytics
PRE_STAGE_STEPS = %w[facts intro].freeze
POST_STAGE_STEPS = %w[done results].freeze
# Bucket for players tagged with a country we hold no headcount for.
OTHER_COUNTRY_KEY = "other".freeze
PERIODS = {
"all" => nil,
"24h" => 1.day,
@ -186,8 +189,29 @@ class GameAnalytics
end
# Only the countries we hold headcounts for are reported individually -- those
# are exactly the ones that score on the leaderboard, so they are the only ones
# we can act on. Anything else a player is tagged with is folded into a single
# "other" bucket instead of padding the list with countries we do not track.
#
# Reads the headcount file directly rather than the `countries:` key in
# en.yml, so the panel cannot drift out of sync with the leaderboard.
def by_country
@by_country ||= breakdown(players.where.not(country: nil).group(:country).count)
@by_country ||= begin
known = LeaderboardScore.headcounts.keys.map(&:to_s)
counts = players.where.not(country: nil).group(:country).count
tracked, rest = counts.partition { |code, _count| known.include?(code.to_s) }
grouped = tracked.to_h
other = rest.sum { |_code, count| count }
grouped[OTHER_COUNTRY_KEY] = other if other.positive?
# `breakdown` sorts by size, which would strand "Other" in the middle of
# the list; it reads better pinned to the bottom.
rows = breakdown(grouped)
rows.reject { |row| row[:key] == OTHER_COUNTRY_KEY } +
rows.select { |row| row[:key] == OTHER_COUNTRY_KEY }
end
end


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

@ -213,9 +213,10 @@
<div class="analytics-columns">
<%= render "breakdown", title: "Languages", rows: @analytics.by_locale, labeller: ->(key) { key } %>
<%= render "breakdown", title: "Languages", rows: @analytics.by_locale,
labeller: ->(key) { language_breakdown_label(key) } %>
<%= render "breakdown", title: "Countries", rows: @analytics.by_country,
labeller: ->(key) { t("countries.#{key}", default: key.to_s.upcase) } %>
labeller: ->(key) { country_breakdown_label(key) } %>
</div>
</div>


+ 1
- 1
app/views/game/results.html.erb View File

@ -86,7 +86,7 @@
</div>
<div class="cta-container">
<%= link_to tag.span(t("game.subscribe_to_newsletter")), "#", class: "cta cta-link" %>
<%= link_to tag.span(t("game.subscribe_to_newsletter")), "https://ikeafoundation.com/newsletter", class: "cta cta-link" %>
<%= button_to start_path(skip_facts: true), class: "cta" do %>
<%= tag.span(t("game.save_another_tomato")) %>


Loading…
Cancel
Save