From 6e898a93657d8033aba63ea98aaca23230f771fa Mon Sep 17 00:00:00 2001 From: Mattias Bodlund Date: Tue, 25 Aug 2026 14:17:52 +0200 Subject: [PATCH] na --- app/assets/stylesheets/application.css | 106 ++++++++++++----------- app/helpers/admin/analytics_helper.rb | 21 +++++ app/services/game_analytics.rb | 26 +++++- app/views/admin/analytics/index.html.erb | 5 +- app/views/game/results.html.erb | 2 +- 5 files changed, 106 insertions(+), 54 deletions(-) diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 6c02aa8..abe49d3 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -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:
stays a flex child of . */ 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 . 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; diff --git a/app/helpers/admin/analytics_helper.rb b/app/helpers/admin/analytics_helper.rb index b65c9f1..7806954 100644 --- a/app/helpers/admin/analytics_helper.rb +++ b/app/helpers/admin/analytics_helper.rb @@ -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? diff --git a/app/services/game_analytics.rb b/app/services/game_analytics.rb index c0ca58d..f990f42 100644 --- a/app/services/game_analytics.rb +++ b/app/services/game_analytics.rb @@ -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 diff --git a/app/views/admin/analytics/index.html.erb b/app/views/admin/analytics/index.html.erb index 1899856..2da6720 100644 --- a/app/views/admin/analytics/index.html.erb +++ b/app/views/admin/analytics/index.html.erb @@ -213,9 +213,10 @@
- <%= 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) } %>
diff --git a/app/views/game/results.html.erb b/app/views/game/results.html.erb index 21c54d7..1ed2d46 100644 --- a/app/views/game/results.html.erb +++ b/app/views/game/results.html.erb @@ -86,7 +86,7 @@
- <%= 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")) %>