diff --git a/Gemfile b/Gemfile index f7c9510..e2426ec 100644 --- a/Gemfile +++ b/Gemfile @@ -36,6 +36,9 @@ gem "acts_as_list" gem "date_validator" +# Resolve a visitor's country from their request IP [https://github.com/alexreisner/geocoder] +gem "geocoder" + gem "premailer-rails" gem "dkim" diff --git a/Gemfile.lock b/Gemfile.lock index 5e6a414..54af762 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -93,10 +93,11 @@ GEM builder (3.3.0) concurrent-ruby (1.3.7) connection_pool (3.0.2) - crass (1.0.6) + crass (1.0.7) css_parser (3.0.0) addressable ssrf_filter (~> 1.5) + csv (3.3.5) date (3.5.1) date_validator (0.12.0) activemodel (>= 3) @@ -117,6 +118,9 @@ GEM ffi (1.17.4-x86_64-darwin) ffi (1.17.4-x86_64-linux-gnu) ffi (1.17.4-x86_64-linux-musl) + geocoder (1.8.6) + base64 (>= 0.1.0) + csv (>= 3.0.0) globalid (1.4.0) activesupport (>= 6.1) htmlentities (4.4.2) @@ -152,14 +156,14 @@ GEM kaminari-core (= 1.2.2) kaminari-core (1.2.2) language_server-protocol (3.17.0.5) - lexxy (0.9.20) + lexxy (0.9.22) rails (>= 8.0.2) lint_roller (1.1.0) logger (1.7.0) loofah (2.25.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) - mail (2.9.0) + mail (2.9.1) logger mini_mime (>= 0.1.1) net-imap @@ -233,9 +237,6 @@ GEM actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack - psych (5.4.0) - date - stringio public_suffix (7.0.5) puma (8.0.2) nio4r (~> 2.0) @@ -280,9 +281,14 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.4.2) - rdoc (7.2.0) + rbs (4.0.3) + logger + prism (>= 1.6.0) + tsort + rdoc (8.0.0) erb - psych (>= 4.0.0) + prism (>= 1.6.0) + rbs (>= 4.0.0) tsort redis (5.4.1) redis-client (>= 0.22.0) @@ -293,7 +299,7 @@ GEM io-console (~> 0.5) request_store (1.7.0) rack (>= 1.4) - rubocop (1.88.0) + rubocop (1.88.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -335,13 +341,12 @@ GEM ssrf_filter (1.5.0) stimulus-rails (1.3.4) railties (>= 6.0.0) - stringio (3.2.0) thor (1.5.0) - thruster (0.1.21) - thruster (0.1.21-aarch64-linux) - thruster (0.1.21-arm64-darwin) - thruster (0.1.21-x86_64-darwin) - thruster (0.1.21-x86_64-linux) + thruster (0.1.22) + thruster (0.1.22-aarch64-linux) + thruster (0.1.22-arm64-darwin) + thruster (0.1.22-x86_64-darwin) + thruster (0.1.22-x86_64-linux) timeout (0.6.1) tsort (0.2.0) turbo-rails (2.0.23) @@ -384,6 +389,7 @@ DEPENDENCIES date_validator dkim dotenv-rails + geocoder image_processing (~> 1.2) importmap-rails jbuilder @@ -431,8 +437,9 @@ CHECKSUMS bundler (4.0.14) sha256=d09a0a965cf772266a7e49e83610be7c2f4e49e61134c42a56804bb383cc24b8 concurrent-ruby (1.3.7) sha256=4412caec3a5ea2e5fdc52076724c071a81f2c0593d83b2ac8cbb8ca63b3151b0 connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a - crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d + crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 css_parser (3.0.0) sha256=eaf0e9283fd581d06e815235ceef4f0910c0b394c606355dbc69f93e84443885 + csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 date_validator (0.12.0) sha256=68c9834da240347b9c17441c553a183572508617ebfbe8c020020f3192ce3058 dkim (1.1.0) sha256=74f2e3075c89cd2967995f3ee293da9dccccffee21ee593715a068ab39a717f8 @@ -449,6 +456,7 @@ CHECKSUMS ffi (1.17.4-x86_64-darwin) sha256=aa70390523cf3235096cf64962b709b4cfbd5c082a2cb2ae714eb0fe2ccda496 ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d ffi (1.17.4-x86_64-linux-musl) sha256=3fdf9888483de005f8ef8d1cf2d3b20d86626af206cbf780f6a6a12439a9c49e + geocoder (1.8.6) sha256=e0ca1554b499f466de9b003f7dff70f89a5888761c2ca68ed9f86b6e5e24e74c globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427 htmlentities (4.4.2) sha256=bbafbdf69f2eca9262be4efef7e43e6a1de54c95eb600f26984f71d2fe96c5c3 i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5 @@ -463,11 +471,11 @@ CHECKSUMS kaminari-activerecord (1.2.2) sha256=0dd3a67bab356a356f36b3b7236bcb81cef313095365befe8e98057dd2472430 kaminari-core (1.2.2) sha256=3bd26fec7370645af40ca73b9426a448d09b8a8ba7afa9ba3c3e0d39cdbb83ff language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc - lexxy (0.9.20) sha256=31b4e50b68302e9a4d00b5a3e104f3b27a0682d43510a1e9e6673da695038e6e + lexxy (0.9.22) sha256=976c9c96e73078937b97942962ec80f32adfb59315921acaaeb30a4cb8a7306e lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 loofah (2.25.1) sha256=d436c73dbd0c1147b16c4a41db097942d217303e1f7728704b37e4df9f6d2e04 - mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941 + mail (2.9.1) sha256=06574eca475253d6c18145dd70af80d0eb970182d55053497c5f4d797ea160e8 marcel (1.2.1) sha256=1678e9360e32f9eafa917c80029e2f6d10b2715c66a4b87b6d0da9b9cd1f859f mini_magick (5.3.1) sha256=29395dfd76badcabb6403ee5aff6f681e867074f8f28ce08d78661e9e4a351c4 mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef @@ -504,7 +512,6 @@ CHECKSUMS prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 propshaft (1.3.2) sha256=1d56a3e56a92c21bfc29caf07406b5386b00d4c47ddf357cf989a5a234b1389e - psych (5.4.0) sha256=14f72d69a611af663d7d70e4a7b67d9eb1f3ae9f8d916b478961d5a0075ba5b7 public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f @@ -518,13 +525,14 @@ CHECKSUMS railties (8.1.3) sha256=913eb0e0cb520aac687ffd74916bd726d48fa21f47833c6292576ef6a286de22 rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 - rdoc (7.2.0) sha256=8650f76cd4009c3b54955eb5d7e3a075c60a57276766ebf36f9085e8c9f23192 + rbs (4.0.3) sha256=5a7bf70e2628549d9a1f44eae447b2cfe55968a9c60cfff52693a4bdcc020e14 + rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469 redis (5.4.1) sha256=b5e675b57ad22b15c9bcc765d5ac26f60b675408af916d31527af9bd5a81faae redis-client (0.30.0) sha256=743f11ed42f0a41a0341554087b077479fec7e2d47a7c123fd90a12c0db5e477 regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 request_store (1.7.0) sha256=e1b75d5346a315f452242a68c937ef8e48b215b9453a77a6c0acdca2934c88cb - rubocop (1.88.0) sha256=e420ddf1662d0ef34bc8a2910ac4b396a7ddda0b51a708264405241734b08e0b + rubocop (1.88.1) sha256=726af773d6bc169ed3ff852f3ca020b7c58d39c34e7a8d879a8e0147cc994f26 rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035 rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 rubocop-rails (2.35.5) sha256=f00b3c936002ba8e9ac62e8607c54bb24cda44b36e41b9c7e4f3872e1b0f3fe3 @@ -535,13 +543,12 @@ CHECKSUMS sidekiq (8.1.6) sha256=be20cd051124b1a16cf97ea9157137abbd30a515c16a5ae9312d2eadd045e40f ssrf_filter (1.5.0) sha256=e03dcdb9d1730d7f6710532a606b3543df2a448a0293ce04a2d995523c5a97f6 stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 - stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 - thruster (0.1.21) sha256=dc67928f36e5894844579a95e45637a5091db7a7ea05468ee8c2c6eb0a3f77cf - thruster (0.1.21-aarch64-linux) sha256=f5aff78fb7a6431ed3d6ab4bde03a89c461e9a73981dbc97d6990d85c3db235c - thruster (0.1.21-arm64-darwin) sha256=bd8db9f57fae2cbb3fe08ebab49cb47fe49608122dac23daf0ce709adfb9bfc8 - thruster (0.1.21-x86_64-darwin) sha256=ccd6acd144fad27856800edfa0573944018333fac8e10a2e5d09726b70c8b0db - thruster (0.1.21-x86_64-linux) sha256=6e2fbcf826540a72d3710ae4db072c2333287ac2ee57e7e52f35bc10900d74a7 + thruster (0.1.22) sha256=f34fb82bd3e31570ae8f8b930e484f9e1db7b080cbc993d49090b161879c3e05 + thruster (0.1.22-aarch64-linux) sha256=6c306241e8ff8912fb26705a15cbb7b44a4dfcedd59fa890b2c72811f15cfa0e + thruster (0.1.22-arm64-darwin) sha256=d0506142832b1b020ab687dbfb938ec7cb07eeaabebc7f8682a73e8f0636917b + thruster (0.1.22-x86_64-darwin) sha256=2598d4122176bebd9d2debfcc5d330886168ca95e1e4705a5f38e80734c4e959 + thruster (0.1.22-x86_64-linux) sha256=b96436be7f0854db632f421b4a53b795429a0bc95c15c2e1c1a7a6fdd1ed090f timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f diff --git a/app/controllers/api/v1/leaderboard_controller.rb b/app/controllers/api/v1/leaderboard_controller.rb index 70f3d1f..d43a176 100644 --- a/app/controllers/api/v1/leaderboard_controller.rb +++ b/app/controllers/api/v1/leaderboard_controller.rb @@ -9,10 +9,19 @@ class Api::V1::LeaderboardController < ApplicationController def index request.format = :json - @leaderboard = I18n.t("countries").keys.map do |code| - { country_code: code.upcase, score: rand(1000..9999) } - end.sort_by { |e| -e[:score] } - @updated_at = Time.current + data = Rails.cache.fetch("api/v1/leaderboard", expires_in: 1.minute) do + DemoActivity.simulate! # TEMP: fake gameplay so the banner has moving numbers + leaderboard = LeaderboardScore.call + { + leaderboard: leaderboard, + badges: LeaderboardBadges.call(leaderboard), + updated_at: Time.current + } + end + + @leaderboard = data[:leaderboard] + @badges = data[:badges] + @updated_at = data[:updated_at] end private diff --git a/app/controllers/game_controller.rb b/app/controllers/game_controller.rb index e373c11..090c422 100644 --- a/app/controllers/game_controller.rb +++ b/app/controllers/game_controller.rb @@ -18,7 +18,8 @@ class GameController < ApplicationController # POST def start - player = Player.create(locale: I18n.locale.to_s) + player = Player.create(locale: I18n.locale.to_s, country: country_from_param) + ResolvePlayerCountryJob.perform_later(player.id, request.remote_ip) if player.country.blank? session[:player_id] = player.id redirect_to action: "facts" end @@ -134,6 +135,14 @@ private + # An explicit ?country= override (handy for testing or a fixed-country kiosk). + # When absent, IP-based detection runs asynchronously in + # ResolvePlayerCountryJob so it never blocks the start of the game. + def country_from_param + Player.valid_country_code(params[:country]) + end + + def url_from_param return "" unless params[:url] diff --git a/app/jobs/resolve_player_country_job.rb b/app/jobs/resolve_player_country_job.rb new file mode 100644 index 0000000..37d39c6 --- /dev/null +++ b/app/jobs/resolve_player_country_job.rb @@ -0,0 +1,15 @@ +# Resolves a player's country from their request IP in the background, so the +# external geolocation call never delays the start of the game. Enqueued by +# GameController#start only when no explicit ?country= override was given. +class ResolvePlayerCountryJob < ApplicationJob + queue_as :default + + def perform(player_id, ip) + player = Player.find_by(id: player_id) + return if player.nil? || player.country.present? + + code = Geocoder.search(ip).first&.country_code + country = Player.valid_country_code(code) + player.update(country: country) if country + end +end diff --git a/app/models/player.rb b/app/models/player.rb index 4c6ca75..b82db5f 100644 --- a/app/models/player.rb +++ b/app/models/player.rb @@ -8,6 +8,14 @@ class Player < ApplicationRecord SCORE_KEYS = %w[food_waste emissions income].freeze + # Normalises an arbitrary country code to one of the leaderboard's known + # countries, or nil if it isn't one we track. Shared by the game controller + # (?country= override) and ResolvePlayerCountryJob (IP geolocation). + def self.valid_country_code(code) + code.to_s.downcase.presence_in(I18n.t("countries").keys.map(&:to_s)) + end + + def record_answer(stage, answer_id) stage_data(stage)["answer_id"] = answer_id save diff --git a/app/services/demo_activity.rb b/app/services/demo_activity.rb new file mode 100644 index 0000000..ba96e30 --- /dev/null +++ b/app/services/demo_activity.rb @@ -0,0 +1,22 @@ +# Fakes live gameplay so the banner developer has moving numbers to build +# against. Called on each leaderboard cache refresh (~once a minute); each call +# adds a handful of random players across random countries, most of them done. +# +# TEMPORARY: set ENABLED = false (or delete this) and wipe the players table +# before the game goes live. +class DemoActivity + ENABLED = true + + def self.simulate! + return unless ENABLED + + countries = I18n.t("countries").keys.map(&:to_s) + rand(5..20).times do + Player.create!( + locale: "en", + country: countries.sample, + is_done: rand < 0.7 + ) + end + end +end diff --git a/app/services/leaderboard_badges.rb b/app/services/leaderboard_badges.rb new file mode 100644 index 0000000..488d922 --- /dev/null +++ b/app/services/leaderboard_badges.rb @@ -0,0 +1,102 @@ +# Builds the rotating banner "badges" that sit alongside the ranked leaderboard. +# The banner picks one at a time or cycles through the whole list. +# +# Three kinds, matching the client's brief: +# #1 encouragement "Ketch-up !" bottom 10 featured countries +# #2 new_players "Yay! New players in " countries with recent sign-ups +# " new players in !" (benchmark variant) +# #3 participation ", every tomato counts!" everyone not covered above +# +# Tunables — the client's example numbers, adjust freely: +class LeaderboardBadges + NEW_PLAYER_WINDOW = 24.hours # #2: "new players in the last X hours" + BENCHMARKS = [100, 50, 10] # #2: highest crossed threshold wins + ENCOURAGEMENT_COUNT = 10 # #1: rotate across the bottom N + + # leaderboard: the array returned by LeaderboardScore.call (already ranked). + def self.call(leaderboard) + new(leaderboard).call + end + + def initialize(leaderboard) + @leaderboard = leaderboard + end + + # At most three badges — one random pick of each type. The pick changes on + # each cache refresh, so the banner naturally rotates through countries. + def call + [ + new_player_badges.sample, + encouragement_badges.sample, + participation_badges.sample + ].compact + end + + private + + # #2 — countries that gained new players (started sessions) within the window. + def new_player_badges + new_player_counts.map do |code, count| + benchmark = BENCHMARKS.find { |b| count >= b } + message = if benchmark + "#{benchmark} new players in #{country_name(code)}!" + else + "Yay! New players in #{country_name(code)}" + end + badge(:new_players, code, message) + end + end + + # #1 — encouragement for the bottom N countries that are actually playing. + def encouragement_badges + bottom_featured.map do |code| + badge(:encouragement, code, "Ketch-up #{country_name(code)}!") + end + end + + # #3 — everyone not featured on the leaderboard and not covered by #1 or #2. + def participation_badges + covered = (new_player_counts.keys + bottom_featured).to_set + unfeatured_codes.reject { |code| covered.include?(code) }.map do |code| + badge(:participation, code, "#{country_name(code)}, every tomato counts!") + end + end + + # Countries with at least one tomato — the ones "on" the leaderboard. + def featured + @featured ||= @leaderboard.select { |row| row[:tomatoes].to_i.positive? } + end + + # @leaderboard is ranked high→low, so the last N featured are the bottom N. + def bottom_featured + @bottom_featured ||= featured.last(ENCOURAGEMENT_COUNT).map { |row| code_of(row) } + end + + def unfeatured_codes + featured_set = featured.map { |row| code_of(row) }.to_set + @leaderboard.map { |row| code_of(row) }.reject { |code| featured_set.include?(code) } + end + + def new_player_counts + @new_player_counts ||= Player + .where(country: known_codes) + .where(created_at: NEW_PLAYER_WINDOW.ago..) + .group(:country).count + end + + def known_codes + @known_codes ||= I18n.t("countries").keys.map(&:to_s) + end + + def code_of(row) + row[:country_code].to_s.downcase + end + + def country_name(code) + I18n.t("countries.#{code}", default: code.to_s.upcase) + end + + def badge(type, code, message) + { type: type.to_s, country_code: code.to_s.upcase, message: message } + end +end diff --git a/app/services/leaderboard_score.rb b/app/services/leaderboard_score.rb new file mode 100644 index 0000000..5fa7ac3 --- /dev/null +++ b/app/services/leaderboard_score.rb @@ -0,0 +1,74 @@ +# Computes the country leaderboard using a blended score that balances overall +# impact with per-employee efficiency, so bigger teams don't win on size alone. +# +# For each country: +# tomatoes = number of finished play sessions (players where is_done) +# per_employee = tomatoes / employee headcount (nil if headcount unknown) +# impact_index = tomatoes / max(tomatoes) * 100 +# efficiency = per_employee / max(per_employee) * 100 +# score = impact_index * IMPACT_WEIGHT + efficiency * EFFICIENCY_WEIGHT +# +# Tune the balance here. The proposal's default is an even 50/50 split; raise +# EFFICIENCY_WEIGHT to lean more toward fairness across team sizes. +class LeaderboardScore + IMPACT_WEIGHT = 0.5 + EFFICIENCY_WEIGHT = 1.0 - IMPACT_WEIGHT + + def self.call + new.call + end + + def call + rows = build_rows + max_tomatoes = rows.map { |r| r[:tomatoes] }.max.to_f + max_per_emp = rows.filter_map { |r| r[:per_employee] }.max.to_f + + rows.map { |r| score_row(r, max_tomatoes, max_per_emp) } + .sort_by { |r| [ -r[:score], r[:country_code] ] } + end + + private + + # One row per known country, so the API always lists every country. Only + # players that are done AND tagged with a known country contribute tomatoes; + # countries with no finished players still appear, with 0 tomatoes / 0 score. + def build_rows + known = I18n.t("countries").keys.map(&:to_s) + counts = Player.where(is_done: true, country: known).group(:country).count + headcounts = self.class.headcounts + + known.map do |country| + tomatoes = counts[country].to_i + employees = headcounts[country].to_i + per_employee = employees.positive? ? tomatoes.to_f / employees : nil + { country: country, tomatoes: tomatoes, per_employee: per_employee } + end + end + + def score_row(row, max_tomatoes, max_per_emp) + impact = max_tomatoes.positive? ? row[:tomatoes] / max_tomatoes * 100 : 0.0 + efficiency = if row[:per_employee] && max_per_emp.positive? + row[:per_employee] / max_per_emp * 100 + else + # No headcount for this country: fall back to impact only rather than + # penalising it with a zero efficiency score. + impact + end + + blended = impact * IMPACT_WEIGHT + efficiency * EFFICIENCY_WEIGHT + + { + country_code: row[:country].to_s.upcase, + score: blended.round, + tomatoes: row[:tomatoes], + per_employee: row[:per_employee]&.round(2) + } + end + + def self.headcounts + @headcounts ||= YAML.safe_load_file( + Rails.root.join("config", "country_headcounts.yml"), + permitted_classes: [], aliases: false + ) || {} + end +end diff --git a/app/views/api/v1/leaderboard/index.json.jbuilder b/app/views/api/v1/leaderboard/index.json.jbuilder index 175d16a..ab3d16e 100644 --- a/app/views/api/v1/leaderboard/index.json.jbuilder +++ b/app/views/api/v1/leaderboard/index.json.jbuilder @@ -1,6 +1,14 @@ json.leaderboard @leaderboard do |entry| json.country_code entry[:country_code] json.score entry[:score] + json.tomatoes entry[:tomatoes] + json.per_employee entry[:per_employee] +end + +json.badges @badges do |badge| + json.type badge[:type] + json.country_code badge[:country_code] + json.message badge[:message] end json.updated_at @updated_at.iso8601 diff --git a/config/country_headcounts.yml b/config/country_headcounts.yml new file mode 100644 index 0000000..ea6891d --- /dev/null +++ b/config/country_headcounts.yml @@ -0,0 +1,73 @@ +# Employee headcount per country, used to compute the leaderboard's +# per-employee (efficiency) score. Keys are ISO country codes and MUST match +# the keys under `countries:` in config/locales/en.yml. +# +# The numbers below are PLACEHOLDER TEST VALUES — update as HR confirms them. +# Any country left blank (nil) or with a non-positive value is excluded from the +# efficiency axis so we never divide by zero — it still counts toward total +# tomatoes / impact. +# +# NOTE: Balearic Islands and Canary Islands are folded into Spain (es) in +# en.yml, so they have no separate key here. +--- +au: 1200 # Australia +at: 950 # Austria +bh: 180 # Bahrain +es: 3100 # Spain (incl. Balearic & Canary Islands) +be: 1400 # Belgium +bg: 600 # Bulgaria +ca: 2600 # Canada +cl: 700 # Chile +cn: 5200 # China +co: 850 # Colombia +hr: 400 # Croatia +cy: 150 # Cyprus +cz: 1100 # Czech Republic +dk: 1300 # Denmark +do: 220 # Dominican Republic +eg: 900 # Egypt +ee: 300 # Estonia +fi: 1050 # Finland +fr: 3800 # France +de: 4600 # Germany +gr: 750 # Greece +hk: 500 # Hong Kong +hu: 950 # Hungary +is: 160 # Iceland +in: 6100 # India +id: 2400 # Indonesia +ie: 1150 # Ireland +il: 680 # Israel +it: 3300 # Italy +jp: 3900 # Japan +jo: 260 # Jordan +kw: 210 # Kuwait +lv: 320 # Latvia +lt: 360 # Lithuania +my: 1600 # Malaysia +mx: 2200 # Mexico +ma: 540 # Morocco +nl: 2100 # Netherlands +nz: 480 # New Zealand +"no": 1250 # Norway +om: 190 # Oman +ph: 1900 # Philippines +pl: 800 # Poland +pt: 1000 # Portugal +pr: 240 # Puerto Rico +qa: 230 # Qatar +ro: 1150 # Romania +sa: 1050 # Saudi Arabia +rs: 560 # Serbia +sg: 900 # Singapore +sk: 620 # Slovakia +si: 340 # Slovenia +kr: 1800 # South Korea +se: 2900 # Sweden +ch: 1500 # Switzerland +tw: 1350 # Taiwan +th: 1700 # Thailand +tr: 2000 # Turkey +ae: 700 # United Arab Emirates +gb: 4100 # United Kingdom +us: 4280 # United States of America diff --git a/config/importmap.rb b/config/importmap.rb index 7c90c37..5a4a3fa 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -6,7 +6,7 @@ pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" pin_all_from "app/javascript/controllers", under: "controllers" pin "sortablejs" # @1.15.7 -pin "stimulus-use" # @0.52.3 +pin "stimulus-use" # @0.53.0 pin "@rails/request.js", to: "@rails--request.js.js" # @0.0.13 pin "@rails/activestorage", to: "@rails--activestorage.js" # @8.1.300 pin "lexxy", to: "lexxy.js" diff --git a/config/initializers/geocoder.rb b/config/initializers/geocoder.rb new file mode 100644 index 0000000..6dd5d7e --- /dev/null +++ b/config/initializers/geocoder.rb @@ -0,0 +1,18 @@ +Geocoder.configure( + # IP-address geolocation service used by `request.location`. + # ipinfo.io works without an API key at low volume; add an ipinfo_token to the + # Rails credentials to raise the rate limit. For a no-rate-limit / + # privacy-friendly setup, swap this for an offline MaxMind GeoLite2 lookup + # (ip_lookup: :geoip2 + the .mmdb file). + ip_lookup: :ipinfo_io, + ipinfo_io: { api_key: Rails.application.credentials.ipinfo_token.presence }, + + # A slow or unreachable geo service must never hold up the start of the game. + timeout: 2, + always_raise: [], # swallow lookup errors -> request.location returns nil + + # Employees behind the same office gateway share an egress IP, so cache each + # lookup and reuse it instead of hammering the API once per player. + cache: Rails.cache, + cache_options: { expiration: 1.day } +) diff --git a/db/migrate/20260703000000_add_country_to_players.rb b/db/migrate/20260703000000_add_country_to_players.rb new file mode 100644 index 0000000..abc5a9f --- /dev/null +++ b/db/migrate/20260703000000_add_country_to_players.rb @@ -0,0 +1,6 @@ +class AddCountryToPlayers < ActiveRecord::Migration[8.1] + def change + add_column :players, :country, :string + add_index :players, :country + end +end diff --git a/db/schema.rb b/db/schema.rb index a781d03..f2d8830 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_06_05_000001) do +ActiveRecord::Schema[8.1].define(version: 2026_07_03_000000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -115,6 +115,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_05_000001) do end create_table "players", force: :cascade do |t| + t.string "country" t.datetime "created_at", null: false t.boolean "is_done", default: false, null: false t.string "locale", null: false @@ -122,6 +123,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_06_05_000001) do t.integer "score", default: 0, null: false t.jsonb "scores", default: {}, null: false t.datetime "updated_at", null: false + t.index ["country"], name: "index_players_on_country" t.index ["is_done"], name: "index_players_on_is_done" t.index ["locale"], name: "index_players_on_locale" end diff --git a/docs/leaderboard_api.md b/docs/leaderboard_api.md new file mode 100644 index 0000000..3b2cd49 --- /dev/null +++ b/docs/leaderboard_api.md @@ -0,0 +1,57 @@ +# Tomato Challenge — Leaderboard API + +Provides the data for the intranet score banner: a ranked list of countries plus +up to three rotating "badge" messages. + +## Endpoint +``` +GET https://savethetomato.ikeafoundation.org/api/v1/leaderboard +``` + +## Authentication +Send a Bearer token in the header: +``` +Authorization: Bearer ikea-tomato-2026 +``` +Missing/wrong token returns `401 { "error": "Unauthorized" }`. + +## Response +```json +{ + "leaderboard": [ + { "country_code": "DK", "score": 100, "tomatoes": 5, "per_employee": 1.2 }, + { "country_code": "PL", "score": 63, "tomatoes": 3, "per_employee": 0.9 } + ], + "badges": [ + { "type": "new_players", "country_code": "FR", "message": "10 new players in France!" }, + { "type": "encouragement", "country_code": "SE", "message": "Ketch-up Sweden!" }, + { "type": "participation", "country_code": "CN", "message": "China, every tomato counts!" } + ], + "updated_at": "2026-07-03T12:00:00Z" +} +``` + +### `leaderboard` +Always contains **all countries**, sorted best-to-worst by `score`. +- `country_code` — ISO 2-letter code, uppercase. +- `score` — blended 0–100 ranking score. +- `tomatoes` — total saved (whole number). +- `per_employee` — tomatoes per employee (decimal, may be `null`). + +### `badges` +Zero to three messages, **at most one of each `type`**. Render `message` as-is. +- `new_players` — a country just gained players. +- `encouragement` — nudge for a trailing country. +- `participation` — a country not yet on the board. + +`country_code` is included if you want to show a flag alongside the message. + +### `updated_at` +ISO-8601 timestamp of the data. + +## Notes +- Response is **cached for ~1 minute**, so numbers/badges change about once a + minute — safe to poll on that cadence. +- The **field/structure is stable**; exact scoring and badge wording may still be + tuned. Don't hard-code specific messages. +- Data is currently **simulated** for development and will be reset before go-live. diff --git a/docs/leaderboard_api.pdf b/docs/leaderboard_api.pdf new file mode 100644 index 0000000..03e85d0 Binary files /dev/null and b/docs/leaderboard_api.pdf differ diff --git a/vendor/javascript/stimulus-use.js b/vendor/javascript/stimulus-use.js index a7e6473..5bc125c 100644 --- a/vendor/javascript/stimulus-use.js +++ b/vendor/javascript/stimulus-use.js @@ -1,4 +1,4 @@ -// stimulus-use@0.52.3 downloaded from https://ga.jspm.io/npm:stimulus-use@0.52.3/dist/index.js +// stimulus-use@0.53.0 downloaded from https://ga.jspm.io/npm:stimulus-use@0.53.0/dist/index.js -import{Controller as e}from"@hotwired/stimulus";const method=(e,t)=>{const s=e[t];return typeof s=="function"?s:(...e)=>{}};const composeEventName=(e,t,s)=>{let n=e;s===true?n=`${t.identifier}:${e}`:typeof s==="string"&&(n=`${s}:${e}`);return n};const extendedEvent=(e,t,s)=>{const{bubbles:n,cancelable:o,composed:i}=t||{bubbles:true,cancelable:true,composed:true};t&&Object.assign(s,{originalEvent:t});const r=new CustomEvent(e,{bubbles:n,cancelable:o,composed:i,detail:s});return r};function isElementInViewport(e){const t=e.getBoundingClientRect();const s=window.innerHeight||document.documentElement.clientHeight;const n=window.innerWidth||document.documentElement.clientWidth;const o=t.top<=s&&t.top+t.height>0;const i=t.left<=n&&t.left+t.width>0;return o&&i}function camelize(e){return e.replace(/(?:[_-])([a-z0-9])/g,((e,t)=>t.toUpperCase()))}function __rest(e,t){var s={};for(var n in e)Object.prototype.hasOwnProperty.call(e,n)&&t.indexOf(n)<0&&(s[n]=e[n]);if(e!=null&&typeof Object.getOwnPropertySymbols==="function"){var o=0;for(n=Object.getOwnPropertySymbols(e);o{if(this.debug){this.logger.groupCollapsed(`%c${this.controller.identifier} %c#${e}`,"color: #3B82F6","color: unset");this.logger.log(Object.assign({controllerId:this.controllerId},t));this.logger.groupEnd()}};this.warn=e=>{this.logger.warn(`%c${this.controller.identifier} %c${e}`,"color: #3B82F6; font-weight: bold","color: unset")};this.dispatch=(e,t={})=>{if(this.dispatchEvent){const{event:s}=t,n=__rest(t,["event"]);const o=this.extendedEvent(e,s||null,n);this.targetElement.dispatchEvent(o);this.log("dispatchEvent",Object.assign({eventName:o.type},n))}};this.call=(e,t={})=>{const s=this.controller[e];if(typeof s=="function")return s.call(this.controller,t)};this.extendedEvent=(e,t,s)=>{const{bubbles:n,cancelable:o,composed:i}=t||{bubbles:true,cancelable:true,composed:true};t&&Object.assign(s,{originalEvent:t});const r=new CustomEvent(this.composeEventName(e),{bubbles:n,cancelable:o,composed:i,detail:s});return r};this.composeEventName=e=>{let t=e;this.eventPrefix===true?t=`${this.controller.identifier}:${e}`:typeof this.eventPrefix==="string"&&(t=`${this.eventPrefix}:${e}`);return t};this.debug=(o=(n=s===null||s===void 0?void 0:s.debug)!==null&&n!==void 0?n:e.application.stimulusUseDebug)!==null&&o!==void 0?o:t.debug;this.logger=(i=s===null||s===void 0?void 0:s.logger)!==null&&i!==void 0?i:t.logger;this.controller=e;this.controllerId=e.element.id||e.element.dataset.id;this.targetElement=(s===null||s===void 0?void 0:s.element)||e.element;const{dispatchEvent:r,eventPrefix:l}=Object.assign({},t,s);Object.assign(this,{dispatchEvent:r,eventPrefix:l});this.controllerInitialize=e.initialize.bind(e);this.controllerConnect=e.connect.bind(e);this.controllerDisconnect=e.disconnect.bind(e)}}const s={eventPrefix:true,bubbles:true,cancelable:true};class UseDispatch extends StimulusUse{constructor(e,t={}){var n,o,i,r;super(e,t);this.dispatch=(e,t={})=>{const{controller:s,targetElement:n,eventPrefix:o,bubbles:i,cancelable:r,log:l,warn:c}=this;Object.assign(t,{controller:s});const a=composeEventName(e,this.controller,o);const u=new CustomEvent(a,{detail:t,bubbles:i,cancelable:r});n.dispatchEvent(u);c("`useDispatch()` is deprecated. Please use the built-in `this.dispatch()` function from Stimulus. You can find more information on how to upgrade at: https://stimulus-use.github.io/stimulus-use/#/use-dispatch");l("dispatch",{eventName:a,detail:t,bubbles:i,cancelable:r});return u};this.targetElement=(n=t.element)!==null&&n!==void 0?n:e.element;this.eventPrefix=(o=t.eventPrefix)!==null&&o!==void 0?o:s.eventPrefix;this.bubbles=(i=t.bubbles)!==null&&i!==void 0?i:s.bubbles;this.cancelable=(r=t.cancelable)!==null&&r!==void 0?r:s.cancelable;this.enhanceController()}enhanceController(){Object.assign(this.controller,{dispatch:this.dispatch})}}const useDispatch=(e,t={})=>new UseDispatch(e,t);const n={overwriteDispatch:true};const useApplication=(e,t={})=>{const{overwriteDispatch:s}=Object.assign({},n,t);Object.defineProperty(e,"isPreview",{get(){return document.documentElement.hasAttribute("data-turbolinks-preview")||document.documentElement.hasAttribute("data-turbo-preview")}});Object.defineProperty(e,"isConnected",{get(){return!!Array.from(this.context.module.connectedContexts).find((e=>e===this.context))}});Object.defineProperty(e,"csrfToken",{get(){return this.metaValue("csrf-token")}});s&&useDispatch(e,t);Object.assign(e,{metaValue(e){const t=document.head.querySelector(`meta[name="${e}"]`);return t&&t.getAttribute("content")}})};class ApplicationController extends e{constructor(e){super(e);this.isPreview=false;this.isConnected=false;this.csrfToken="";useApplication(this,this.options)}}const o={events:["click","touchend"],onlyVisible:true,dispatchEvent:true,eventPrefix:true};const useClickOutside=(e,t={})=>{const s=e;const{onlyVisible:n,dispatchEvent:i,events:r,eventPrefix:l}=Object.assign({},o,t);const onEvent=e=>{const o=(t===null||t===void 0?void 0:t.element)||s.element;if(!(o.contains(e.target)||!isElementInViewport(o)&&n)){s.clickOutside&&s.clickOutside(e);if(i){const t=composeEventName("click:outside",s,l);const n=extendedEvent(t,e,{controller:s});o.dispatchEvent(n)}}};const observe=()=>{r===null||r===void 0?void 0:r.forEach((e=>{window.addEventListener(e,onEvent,true)}))};const unobserve=()=>{r===null||r===void 0?void 0:r.forEach((e=>{window.removeEventListener(e,onEvent,true)}))};const c=s.disconnect.bind(s);Object.assign(s,{disconnect(){unobserve();c()}});observe();return[observe,unobserve]};class ClickOutsideComposableController extends e{}class ClickOutsideController extends ClickOutsideComposableController{constructor(e){super(e);requestAnimationFrame((()=>{const[e,t]=useClickOutside(this,this.options);Object.assign(this,{observe:e,unobserve:t})}))}}class DebounceController extends e{}DebounceController.debounces=[];const i=200;const debounce=(e,t=i)=>{let s=null;return function(){const n=Array.from(arguments);const o=this;const i=n.map((e=>e.params));const callback=()=>{n.forEach(((e,t)=>e.params=i[t]));return e.apply(o,n)};s&&clearTimeout(s);s=setTimeout(callback,t)}};const useDebounce=(e,t)=>{const s=e;const n=s.constructor;n.debounces.forEach((e=>{typeof e==="string"&&(s[e]=debounce(s[e],t===null||t===void 0?void 0:t.wait));if(typeof e==="object"){const{name:n,wait:o}=e;if(!n)return;s[n]=debounce(s[n],o||(t===null||t===void 0?void 0:t.wait))}}))};class UseHover extends StimulusUse{constructor(e,t={}){super(e,t);this.observe=()=>{this.targetElement.addEventListener("mouseenter",this.onEnter);this.targetElement.addEventListener("mouseleave",this.onLeave)};this.unobserve=()=>{this.targetElement.removeEventListener("mouseenter",this.onEnter);this.targetElement.removeEventListener("mouseleave",this.onLeave)};this.onEnter=e=>{this.call("mouseEnter",e);this.log("mouseEnter",{hover:true});this.dispatch("mouseEnter",{hover:false})};this.onLeave=e=>{this.call("mouseLeave",e);this.log("mouseLeave",{hover:false});this.dispatch("mouseLeave",{hover:false})};this.controller=e;this.enhanceController();this.observe()}enhanceController(){const e=this.controller.disconnect.bind(this.controller);const disconnect=()=>{this.unobserve();e()};Object.assign(this.controller,{disconnect:disconnect})}}const useHover=(e,t={})=>{const s=e;const n=new UseHover(s,t);return[n.observe,n.unobserve]};class HoverComposableController extends e{}class HoverController extends HoverComposableController{constructor(e){super(e);requestAnimationFrame((()=>{const[e,t]=useHover(this,this.options);Object.assign(this,{observe:e,unobserve:t})}))}}const r=["mousemove","mousedown","resize","keydown","touchstart","wheel"];const l=6e4;const c={ms:l,initialState:false,events:r,dispatchEvent:true,eventPrefix:true};const useIdle=(e,t={})=>{const s=e;const{ms:n,initialState:o,events:i,dispatchEvent:r,eventPrefix:l}=Object.assign({},c,t);let a=o;let u=setTimeout((()=>{a=true;dispatchAway()}),n);const dispatchAway=e=>{const t=composeEventName("away",s,l);s.isIdle=true;method(s,"away").call(s,e);if(r){const n=extendedEvent(t,e||null,{controller:s});s.element.dispatchEvent(n)}};const dispatchBack=e=>{const t=composeEventName("back",s,l);s.isIdle=false;method(s,"back").call(s,e);if(r){const n=extendedEvent(t,e||null,{controller:s});s.element.dispatchEvent(n)}};const onEvent=e=>{a&&dispatchBack(e);a=false;clearTimeout(u);u=setTimeout((()=>{a=true;dispatchAway(e)}),n)};const onVisibility=e=>{document.hidden||onEvent(e)};a?dispatchAway():dispatchBack();const h=s.disconnect.bind(s);const observe=()=>{i.forEach((e=>{window.addEventListener(e,onEvent)}));document.addEventListener("visibilitychange",onVisibility)};const unobserve=()=>{clearTimeout(u);i.forEach((e=>{window.removeEventListener(e,onEvent)}));document.removeEventListener("visibilitychange",onVisibility)};Object.assign(s,{disconnect(){unobserve();h()}});observe();return[observe,unobserve]};class IdleComposableController extends e{constructor(){super(...arguments);this.isIdle=false}}class IdleController extends IdleComposableController{constructor(e){super(e);requestAnimationFrame((()=>{const[e,t]=useIdle(this,this.options);Object.assign(this,{observe:e,unobserve:t})}))}}const a={dispatchEvent:true,eventPrefix:true,visibleAttribute:"isVisible"};const useIntersection=(e,t={})=>{const s=e;const{dispatchEvent:n,eventPrefix:o,visibleAttribute:i}=Object.assign({},a,t);const r=(t===null||t===void 0?void 0:t.element)||s.element;s.intersectionElements||(s.intersectionElements=[]);s.intersectionElements.push(r);const callback=e=>{const[t]=e;t.isIntersecting?dispatchAppear(t):r.hasAttribute(i)&&dispatchDisappear(t)};const l=new IntersectionObserver(callback,t);const dispatchAppear=e=>{r.setAttribute(i,"true");method(s,"appear").call(s,e,l);if(n){const t=composeEventName("appear",s,o);const n=extendedEvent(t,null,{controller:s,entry:e,observer:l});r.dispatchEvent(n)}};const dispatchDisappear=e=>{r.removeAttribute(i);method(s,"disappear").call(s,e,l);if(n){const t=composeEventName("disappear",s,o);const n=extendedEvent(t,null,{controller:s,entry:e,observer:l});r.dispatchEvent(n)}};const c=s.disconnect.bind(s);const disconnect=()=>{unobserve();c()};const observe=()=>{l.observe(r)};const unobserve=()=>{l.unobserve(r)};const noneVisible=()=>s.intersectionElements.filter((e=>e.hasAttribute(i))).length===0;const oneVisible=()=>s.intersectionElements.filter((e=>e.hasAttribute(i))).length===1;const atLeastOneVisible=()=>s.intersectionElements.some((e=>e.hasAttribute(i)));const allVisible=()=>s.intersectionElements.every((e=>e.hasAttribute(i)));const u=allVisible;Object.assign(s,{isVisible:u,noneVisible:noneVisible,oneVisible:oneVisible,atLeastOneVisible:atLeastOneVisible,allVisible:allVisible,disconnect:disconnect});observe();return[observe,unobserve]};class IntersectionComposableController extends e{}class IntersectionController extends IntersectionComposableController{constructor(e){super(e);requestAnimationFrame((()=>{const[e,t]=useIntersection(this,this.options);Object.assign(this,{observe:e,unobserve:t})}))}}const useLazyLoad=(e,t)=>{const callback=t=>{const[s]=t;s.isIntersecting&&!e.isLoaded&&handleAppear()};const handleAppear=t=>{const s=e.data.get("src");if(!s)return;const n=e.element;e.isLoading=true;method(e,"loading").call(e,s);n.onload=()=>{handleLoaded(s)};n.src=s};const handleLoaded=t=>{e.isLoading=false;e.isLoaded=true;method(e,"loaded").call(e,t)};const s=e.disconnect.bind(e);const n=new IntersectionObserver(callback,t);const observe=()=>{n.observe(e.element)};const unobserve=()=>{n.unobserve(e.element)};Object.assign(e,{isVisible:false,disconnect(){unobserve();s()}});observe();return[observe,unobserve]};class LazyLoadComposableController extends e{constructor(){super(...arguments);this.isLoading=false;this.isLoaded=false}}class LazyLoadController extends LazyLoadComposableController{constructor(e){super(e);this.options={rootMargin:"10%"};requestAnimationFrame((()=>{const[e,t]=useLazyLoad(this,this.options);Object.assign(this,{observe:e,unobserve:t})}))}}const u={mediaQueries:{},dispatchEvent:true,eventPrefix:true,debug:false};class UseMatchMedia extends StimulusUse{constructor(e,t={}){var s,n,o,i;super(e,t);this.matches=[];this.callback=e=>{const t=Object.keys(this.mediaQueries).find((t=>this.mediaQueries[t]===e.media));if(!t)return;const{media:s,matches:n}=e;this.changed({name:t,media:s,matches:n,event:e})};this.changed=e=>{const{name:t}=e;if(e.event){this.call(camelize(`${t}_changed`),e);this.dispatch(`${t}:changed`,e);this.log(`media query "${t}" changed`,e)}if(e.matches){this.call(camelize(`is_${t}`),e);this.dispatch(`is:${t}`,e)}else{this.call(camelize(`not_${t}`),e);this.dispatch(`not:${t}`,e)}};this.observe=()=>{Object.keys(this.mediaQueries).forEach((e=>{const t=this.mediaQueries[e];const s=window.matchMedia(t);s.addListener(this.callback);this.matches.push(s);this.changed({name:e,media:t,matches:s.matches})}))};this.unobserve=()=>{this.matches.forEach((e=>e.removeListener(this.callback)))};this.controller=e;this.mediaQueries=(s=t.mediaQueries)!==null&&s!==void 0?s:u.mediaQueries;this.dispatchEvent=(n=t.dispatchEvent)!==null&&n!==void 0?n:u.dispatchEvent;this.eventPrefix=(o=t.eventPrefix)!==null&&o!==void 0?o:u.eventPrefix;this.debug=(i=t.debug)!==null&&i!==void 0?i:u.debug;if(window.matchMedia){this.enhanceController();this.observe()}else console.error("window.matchMedia() is not available")}enhanceController(){const e=this.controller.disconnect.bind(this.controller);const disconnect=()=>{this.unobserve();e()};Object.assign(this.controller,{disconnect:disconnect})}}const useMatchMedia=(e,t={})=>{const s=new UseMatchMedia(e,t);return[s.observe,s.unobserve]};const memoize=(e,t,s)=>{Object.defineProperty(e,t,{value:s});return s};const useMemo=e=>{var t;(t=e.constructor.memos)===null||t===void 0?void 0:t.forEach((t=>{memoize(e,t,e[t])}))};const defineMetaGetter=(e,t,s)=>{const n=s?`${camelize(t)}Meta`:camelize(t);Object.defineProperty(e,n,{get(){return typeCast(metaValue(t))}})};function metaValue(e){const t=document.head.querySelector(`meta[name="${e}"]`);return t&&t.getAttribute("content")}function typeCast(e){try{return JSON.parse(e)}catch(t){return e}}const useMeta=(e,t={suffix:true})=>{const s=e.constructor.metaNames;const n=t.suffix;s===null||s===void 0?void 0:s.forEach((t=>{defineMetaGetter(e,t,n)}));Object.defineProperty(e,"metas",{get(){const e={};s===null||s===void 0?void 0:s.forEach((t=>{const s=typeCast(metaValue(t));s!==void 0&&s!==null&&(e[camelize(t)]=s)}));return e}})};class UseMutation extends StimulusUse{constructor(e,t={}){super(e,t);this.observe=()=>{try{this.observer.observe(this.targetElement,this.options)}catch(e){this.controller.application.handleError(e,"At a minimum, one of childList, attributes, and/or characterData must be true",{})}};this.unobserve=()=>{this.observer.disconnect()};this.mutation=e=>{this.call("mutate",e);this.log("mutate",{entries:e});this.dispatch("mutate",{entries:e})};this.targetElement=(t===null||t===void 0?void 0:t.element)||e.element;this.controller=e;this.options=t;this.observer=new MutationObserver(this.mutation);this.enhanceController();this.observe()}enhanceController(){const e=this.controller.disconnect.bind(this.controller);const disconnect=()=>{this.unobserve();e()};Object.assign(this.controller,{disconnect:disconnect})}}const useMutation=(e,t={})=>{const s=new UseMutation(e,t);return[s.observe,s.unobserve]};class MutationComposableController extends e{}class MutationController extends MutationComposableController{constructor(e){super(e);requestAnimationFrame((()=>{const[e,t]=useMutation(this,this.options);Object.assign(this,{observe:e,unobserve:t})}))}}const h={dispatchEvent:true,eventPrefix:true};const useResize=(e,t={})=>{const s=e;const{dispatchEvent:n,eventPrefix:o}=Object.assign({},h,t);const i=(t===null||t===void 0?void 0:t.element)||s.element;const callback=e=>{const[t]=e;method(s,"resize").call(s,t.contentRect);if(n){const e=composeEventName("resize",s,o);const n=extendedEvent(e,null,{controller:s,entry:t});i.dispatchEvent(n)}};const r=s.disconnect.bind(s);const l=new ResizeObserver(callback);const observe=()=>{l.observe(i)};const unobserve=()=>{l.unobserve(i)};Object.assign(s,{disconnect(){unobserve();r()}});observe();return[observe,unobserve]};class ResizeComposableController extends e{}class ResizeController extends ResizeComposableController{constructor(e){super(e);requestAnimationFrame((()=>{const[e,t]=useResize(this,this.options);Object.assign(this,{observe:e,unobserve:t})}))}}class UseTargetMutation extends StimulusUse{constructor(e,t={}){super(e,t);this.observe=()=>{this.observer.observe(this.targetElement,{subtree:true,characterData:true,childList:true,attributes:true,attributeOldValue:true,attributeFilter:[this.targetSelector,this.scopedTargetSelector]})};this.unobserve=()=>{this.observer.disconnect()};this.mutation=e=>{for(const t of e)switch(t.type){case"attributes":let e=t.target.getAttribute(t.attributeName);let s=t.oldValue;if(t.attributeName===this.targetSelector||t.attributeName===this.scopedTargetSelector){let n=this.targetsUsedByThisController(s);let o=this.targetsUsedByThisController(e);let i=n.filter((e=>!o.includes(e)));let r=o.filter((e=>!n.includes(e)));i.forEach((e=>this.targetRemoved(this.stripIdentifierPrefix(e),t.target,"attributeChange")));r.forEach((e=>this.targetAdded(this.stripIdentifierPrefix(e),t.target,"attributeChange")))}break;case"characterData":let n=this.findTargetInAncestry(t.target);if(n==null)return;{let e=this.targetsUsedByThisControllerFromNode(n);e.forEach((e=>{this.targetChanged(this.stripIdentifierPrefix(e),n,"domMutation")}))}break;case"childList":let{addedNodes:o,removedNodes:i}=t;o.forEach((e=>this.processNodeDOMMutation(e,this.targetAdded)));i.forEach((e=>this.processNodeDOMMutation(e,this.targetRemoved)));break}};this.controller=e;this.options=t;this.targetElement=e.element;this.identifier=e.scope.identifier;this.identifierPrefix=`${this.identifier}.`;this.targetSelector=e.scope.schema.targetAttribute;this.scopedTargetSelector=`data-${this.identifier}-target`;this.targets=t.targets||e.constructor.targets;this.prefixedTargets=this.targets.map((e=>`${this.identifierPrefix}${e}`));this.observer=new MutationObserver(this.mutation);this.enhanceController();this.observe()}processNodeDOMMutation(e,t){let s=e;let n=t;let o=[];if(s.nodeName=="#text"||this.targetsUsedByThisControllerFromNode(s).length==0){n=this.targetChanged;s=this.findTargetInAncestry(e)}else o=this.targetsUsedByThisControllerFromNode(s);if(s!=null){o.length==0&&(o=this.targetsUsedByThisControllerFromNode(s));o.forEach((e=>{n.call(this,this.stripIdentifierPrefix(e),s,"domMutation")}))}}findTargetInAncestry(e){let t=e;let s=[];t.nodeName!="#text"&&(s=this.targetsUsedByThisControllerFromNode(t));while(t.parentNode!==null&&t.parentNode!=this.targetElement&&s.length==0){t=t.parentNode;if(t.nodeName!=="#text"){let e=this.targetsUsedByThisControllerFromNode(t);if(e.length>0)return t}}return t.nodeName=="#text"||t.parentNode==null?null:t.parentNode==this.targetElement&&this.targetsUsedByThisControllerFromNode(t).length>0?t:null}targetAdded(e,t,s){let n=`${e}TargetAdded`;this.controller[n]&&method(this.controller,n).call(this.controller,t);this.warn("`[target]TargetAdded` is deprecated. Please use the built-in `[target]TargetConnected()` function from Stimulus.");this.log("targetAdded",{target:e,node:t,trigger:s})}targetRemoved(e,t,s){let n=`${e}TargetRemoved`;this.controller[n]&&method(this.controller,n).call(this.controller,t);this.warn("`[target]TargetRemoved` is deprecated. Please use the built-in `[target]TargetDisconnected()` function from Stimulus.");this.log("targetRemoved",{target:e,node:t,trigger:s})}targetChanged(e,t,s){let n=`${e}TargetChanged`;this.controller[n]&&method(this.controller,n).call(this.controller,t);this.log("targetChanged",{target:e,node:t,trigger:s})}targetsUsedByThisControllerFromNode(e){if(e.nodeName=="#text"||e.nodeName=="#comment")return[];let t=e;return this.targetsUsedByThisController(t.getAttribute(this.scopedTargetSelector)||t.getAttribute(this.targetSelector))}targetsUsedByThisController(e){e=e||"";let t=this.stripIdentifierPrefix(e).split(" ");return this.targets.filter((e=>t.indexOf(e)!==-1))}stripIdentifierPrefix(e){return e.replace(new RegExp(this.identifierPrefix,"g"),"")}enhanceController(){const e=this.controller.disconnect.bind(this.controller);const disconnect=()=>{this.unobserve();e()};Object.assign(this.controller,{disconnect:disconnect})}}const useTargetMutation=(e,t={})=>{const s=e;const n=new UseTargetMutation(s,t);n.warn("`[target]TargetAdded` and `[target]TargetRemoved` are deprecated. Please use the built-in `[target]TargetConnected()` and `[target]TargetDisconnected()` functions from Stimulus.");return[n.observe,n.unobserve]};class TargetMutationComposableController extends e{}class TargetMutationController extends TargetMutationComposableController{constructor(e){super(e);requestAnimationFrame((()=>{const[e,t]=useTargetMutation(this,this.options);Object.assign(this,{observe:e,unobserve:t})}))}}class ThrottleController extends e{}ThrottleController.throttles=[];const d=200;function throttle(e,t=d){let s;return function(){const n=arguments;const o=this;if(!s){s=true;e.apply(o,n);setTimeout((()=>s=false),t)}}}const useThrottle=(e,t={})=>{var s;const n=e;const o=n.constructor;(s=o.throttles)===null||s===void 0?void 0:s.forEach((e=>{typeof e==="string"&&(n[e]=throttle(n[e],t===null||t===void 0?void 0:t.wait));if(typeof e==="object"){const{name:s,wait:o}=e;if(!s)return;n[s]=throttle(n[s],o||(t===null||t===void 0?void 0:t.wait))}}))};const b={enterFromClass:"enter",enterActiveClass:"enterStart",enterToClass:"enterEnd",leaveFromClass:"leave",leaveActiveClass:"leaveStart",leaveToClass:"leaveEnd"};const m={transitioned:false,hiddenClass:"hidden",preserveOriginalClass:true,removeToClasses:true};const useTransition=(e,t={})=>{var s,n,o;const i=e;const r=i.element.dataset.transitionTarget;let l;r&&(l=i[`${r}Target`]);const c=(t===null||t===void 0?void 0:t.element)||l||i.element;if(!(c instanceof HTMLElement||c instanceof SVGElement))return;const a=c.dataset;const u=parseInt(a.leaveAfter||"")||t.leaveAfter||0;const{transitioned:h,hiddenClass:d,preserveOriginalClass:b,removeToClasses:v}=Object.assign({},m,t);const g=(s=i.enter)===null||s===void 0?void 0:s.bind(i);const p=(n=i.leave)===null||n===void 0?void 0:n.bind(i);const f=(o=i.toggleTransition)===null||o===void 0?void 0:o.bind(i);async function enter(e){if(i.transitioned)return;i.transitioned=true;g&&g(e);const s=getAttribute("enterFrom",t,a);const n=getAttribute("enterActive",t,a);const o=getAttribute("enterTo",t,a);const r=getAttribute("leaveTo",t,a);!d||c.classList.remove(d);v||removeClasses(c,r);await transition(c,s,n,o,d,b,v);u>0&&setTimeout((()=>{leave(e)}),u)}async function leave(e){if(!i.transitioned)return;i.transitioned=false;p&&p(e);const s=getAttribute("leaveFrom",t,a);const n=getAttribute("leaveActive",t,a);const o=getAttribute("leaveTo",t,a);const r=getAttribute("enterTo",t,a);v||removeClasses(c,r);await transition(c,s,n,o,d,b,v);!d||c.classList.add(d)}function toggleTransition(e){f&&f(e);i.transitioned?leave():enter()}async function transition(e,t,s,n,o,i,r){const l=[];if(i){t.forEach((t=>e.classList.contains(t)&&t!==o&&l.push(t)));s.forEach((t=>e.classList.contains(t)&&t!==o&&l.push(t)));n.forEach((t=>e.classList.contains(t)&&t!==o&&l.push(t)))}addClasses(e,t);removeClasses(e,l);addClasses(e,s);await nextAnimationFrame();removeClasses(e,t);addClasses(e,n);await afterTransition(e);removeClasses(e,s);r&&removeClasses(e,n);addClasses(e,l)}function initialState(){i.transitioned=h;if(h){!d||c.classList.remove(d);enter()}else{!d||c.classList.add(d);leave()}}function addClasses(e,t){t.length>0&&e.classList.add(...t)}function removeClasses(e,t){t.length>0&&e.classList.remove(...t)}initialState();Object.assign(i,{enter:enter,leave:leave,toggleTransition:toggleTransition});return[enter,leave,toggleTransition]};function getAttribute(e,t,s){const n=`transition${e[0].toUpperCase()}${e.substr(1)}`;const o=b[e];const i=t[e]||s[n]||s[o]||" ";return isEmpty(i)?[]:i.split(" ")}async function afterTransition(e){return new Promise((t=>{const s=Number(getComputedStyle(e).transitionDuration.split(",")[0].replace("s",""))*1e3;setTimeout((()=>{t(s)}),s)}))}async function nextAnimationFrame(){return new Promise((e=>{requestAnimationFrame((()=>{requestAnimationFrame(e)}))}))}function isEmpty(e){return e.length===0||!e.trim()}class TransitionComposableController extends e{constructor(){super(...arguments);this.transitioned=false}}class TransitionController extends TransitionComposableController{constructor(e){super(e);requestAnimationFrame((()=>{useTransition(this,this.options)}))}}class UseVisibility extends StimulusUse{constructor(e,t={}){super(e,t);this.observe=()=>{this.controller.isVisible=!document.hidden;document.addEventListener("visibilitychange",this.handleVisibilityChange);this.handleVisibilityChange()};this.unobserve=()=>{document.removeEventListener("visibilitychange",this.handleVisibilityChange)};this.becomesInvisible=e=>{this.controller.isVisible=false;this.call("invisible",e);this.log("invisible",{isVisible:false});this.dispatch("invisible",{event:e,isVisible:false})};this.becomesVisible=e=>{this.controller.isVisible=true;this.call("visible",e);this.log("visible",{isVisible:true});this.dispatch("visible",{event:e,isVisible:true})};this.handleVisibilityChange=e=>{document.hidden?this.becomesInvisible(e):this.becomesVisible(e)};this.controller=e;this.enhanceController();this.observe()}enhanceController(){const e=this.controllerDisconnect;const disconnect=()=>{this.unobserve();e()};Object.assign(this.controller,{disconnect:disconnect})}}const useVisibility=(e,t={})=>{const s=e;const n=new UseVisibility(s,t);return[n.observe,n.unobserve]};class VisibilityComposableController extends e{constructor(){super(...arguments);this.isVisible=false}}class VisibilityController extends VisibilityComposableController{constructor(e){super(e);requestAnimationFrame((()=>{const[e,t]=useVisibility(this,this.options);Object.assign(this,{observe:e,unobserve:t})}))}}class UseWindowFocus extends StimulusUse{constructor(e,t={}){super(e,t);this.observe=()=>{document.hasFocus()?this.becomesFocused():this.becomesUnfocused();this.interval=setInterval((()=>{this.handleWindowFocusChange()}),this.intervalDuration)};this.unobserve=()=>{clearInterval(this.interval)};this.becomesUnfocused=e=>{this.controller.hasFocus=false;this.call("unfocus",e);this.log("unfocus",{hasFocus:false});this.dispatch("unfocus",{event:e,hasFocus:false})};this.becomesFocused=e=>{this.controller.hasFocus=true;this.call("focus",e);this.log("focus",{hasFocus:true});this.dispatch("focus",{event:e,hasFocus:true})};this.handleWindowFocusChange=e=>{document.hasFocus()&&!this.controller.hasFocus?this.becomesFocused(e):!document.hasFocus()&&this.controller.hasFocus&&this.becomesUnfocused(e)};this.controller=e;this.intervalDuration=t.interval||200;this.enhanceController();this.observe()}enhanceController(){const e=this.controllerDisconnect;const disconnect=()=>{this.unobserve();e()};Object.assign(this.controller,{disconnect:disconnect})}}const useWindowFocus=(e,t={})=>{const s=e;const n=new UseWindowFocus(s,t);return[n.observe,n.unobserve]};class WindowFocusComposableController extends e{constructor(){super(...arguments);this.hasFocus=false}}class WindowFocusController extends WindowFocusComposableController{constructor(e){super(e);requestAnimationFrame((()=>{const[e,t]=useWindowFocus(this,this.options);Object.assign(this,{observe:e,unobserve:t})}))}}const useWindowResize=e=>{const t=e;const callback=e=>{const{innerWidth:s,innerHeight:n}=window;const o={height:n||Infinity,width:s||Infinity,event:e};method(t,"windowResize").call(t,o)};const s=t.disconnect.bind(t);const observe=()=>{window.addEventListener("resize",callback);callback()};const unobserve=()=>{window.removeEventListener("resize",callback)};Object.assign(t,{disconnect(){unobserve();s()}});observe();return[observe,unobserve]};class WindowResizeComposableController extends e{}class WindowResizeController extends WindowResizeComposableController{constructor(e){super(e);requestAnimationFrame((()=>{const[e,t]=useWindowResize(this);Object.assign(this,{observe:e,unobserve:t})}))}}function useHotkeys(){throw"[stimulus-use] Notice: The import for `useHotkeys()` has been moved from `stimulus-use` to `stimulus-use/hotkeys`. \nPlease change the import accordingly and add `hotkey-js` as a dependency to your project. \n\nFor more information see: https://stimulus-use.github.io/stimulus-use/#/use-hotkeys?id=importing-the-behavior"}export{ApplicationController,ClickOutsideController,HoverController,IdleController,IntersectionController,LazyLoadController,MutationController,ResizeController,TargetMutationController,TransitionController,UseHover,UseMutation,UseTargetMutation,UseVisibility,UseWindowFocus,VisibilityController,WindowFocusController,WindowResizeController,debounce,useApplication,useClickOutside,useDebounce,useDispatch,useHotkeys,useHover,useIdle,useIntersection,useLazyLoad,useMatchMedia,useMemo,useMeta,useMutation,useResize,useTargetMutation,useThrottle,useTransition,useVisibility,useWindowFocus,useWindowResize}; +import{Controller as e}from"@hotwired/stimulus";const t=(e,t)=>{let n=e[t];return typeof n==`function`?n:(...e)=>{}},n=(e,t,n)=>{let r=e;return n===!0?r=`${t.identifier}:${e}`:typeof n==`string`&&(r=`${n}:${e}`),r},r=(e,t,n)=>{let{bubbles:r,cancelable:i,composed:a}=t||{bubbles:!0,cancelable:!0,composed:!0};return t&&Object.assign(n,{originalEvent:t}),new CustomEvent(e,{bubbles:r,cancelable:i,composed:a,detail:n})};function i(e){let t=e.getBoundingClientRect(),n=window.innerHeight||document.documentElement.clientHeight,r=window.innerWidth||document.documentElement.clientWidth,i=t.top<=n&&t.top+t.height>0,a=t.left<=r&&t.left+t.width>0;return i&&a}function a(e){return e.replace(/(?:[_-])([a-z0-9])/g,((e,t)=>t.toUpperCase()))}const o={debug:!1,logger:console,dispatchEvent:!0,eventPrefix:!0};class StimulusUse{constructor(e,t={}){this.log=(e,t)=>{this.debug&&(this.logger.groupCollapsed(`%c${this.controller.identifier} %c#${e}`,`color: #3B82F6`,`color: unset`),this.logger.log({controllerId:this.controllerId,...t}),this.logger.groupEnd())},this.warn=e=>{this.logger.warn(`%c${this.controller.identifier} %c${e}`,`color: #3B82F6; font-weight: bold`,`color: unset`)},this.dispatch=(e,t={})=>{if(this.dispatchEvent){let{event:n,...r}=t,i=this.extendedEvent(e,n||null,r);this.targetElement.dispatchEvent(i),this.log(`dispatchEvent`,{eventName:i.type,...r})}},this.call=(e,t={})=>{let n=this.controller[e];if(typeof n==`function`)return n.call(this.controller,t)},this.extendedEvent=(e,t,n)=>{let{bubbles:r,cancelable:i,composed:a}=t||{bubbles:!0,cancelable:!0,composed:!0};return t&&Object.assign(n,{originalEvent:t}),new CustomEvent(this.composeEventName(e),{bubbles:r,cancelable:i,composed:a,detail:n})},this.composeEventName=e=>{let t=e;return this.eventPrefix===!0?t=`${this.controller.identifier}:${e}`:typeof this.eventPrefix==`string`&&(t=`${this.eventPrefix}:${e}`),t};let n=e.application;n.stimulusUseDebug!==void 0&&!n.stimulusUseDebugWarned&&(n.stimulusUseDebugWarned=!0,console.warn("[stimulus-use] `application.stimulusUseDebug` is deprecated and will be removed in a future release. Use Stimulus’ built-in debug mode instead: `application.debug = true`. https://stimulus.hotwired.dev/handbook/installing#debugging"));let r=n.debug||n.stimulusUseDebug||o.debug;this.debug=t?.debug??r,this.logger=t?.logger??o.logger,this.controller=e,this.controllerId=e.element.id||e.element.dataset.id,this.targetElement=t?.element||e.element;let{dispatchEvent:i,eventPrefix:a}=Object.assign({},o,t);Object.assign(this,{dispatchEvent:i,eventPrefix:a}),this.controllerInitialize=e.initialize.bind(e),this.controllerConnect=e.connect.bind(e),this.controllerDisconnect=e.disconnect.bind(e)}}const s={eventPrefix:!0,bubbles:!0,cancelable:!0};class UseDispatch extends StimulusUse{constructor(e,t={}){super(e,t),this.dispatch=(e,t={})=>{let{controller:r,targetElement:i,eventPrefix:a,bubbles:o,cancelable:s,log:c,warn:l}=this;Object.assign(t,{controller:r});let u=n(e,this.controller,a),d=new CustomEvent(u,{detail:t,bubbles:o,cancelable:s});return i.dispatchEvent(d),l("`useDispatch()` is deprecated. Please use the built-in `this.dispatch()` function from Stimulus. You can find more information on how to upgrade at: https://stimulus-use.dev/use-dispatch"),c(`dispatch`,{eventName:u,detail:t,bubbles:o,cancelable:s}),d},this.targetElement=t.element??e.element,this.eventPrefix=t.eventPrefix??s.eventPrefix,this.bubbles=t.bubbles??s.bubbles,this.cancelable=t.cancelable??s.cancelable,this.enhanceController()}enhanceController(){Object.assign(this.controller,{dispatch:this.dispatch})}}const c=(e,t={})=>new UseDispatch(e,t),l={overwriteDispatch:!0},u=(e,t={})=>{let{overwriteDispatch:n}=Object.assign({},l,t);Object.defineProperty(e,"isPreview",{get(){return document.documentElement.hasAttribute(`data-turbolinks-preview`)||document.documentElement.hasAttribute(`data-turbo-preview`)}}),Object.defineProperty(e,"isConnected",{get(){return!!Array.from(this.context.module.connectedContexts).find((e=>e===this.context))}}),Object.defineProperty(e,"csrfToken",{get(){return this.metaValue(`csrf-token`)}}),n&&c(e,t),Object.assign(e,{metaValue(e){let t=document.head.querySelector(`meta[name="${e}"]`);return t&&t.getAttribute(`content`)}})};class ApplicationController extends e{constructor(e){super(e),this.isPreview=!1,this.isConnected=!1,this.csrfToken=``,u(this,this.options)}}const d={events:[`click`,`touchend`],onlyVisible:!0,dispatchEvent:!0,eventPrefix:!0},f=(e,t={})=>{let a=e,{onlyVisible:o,dispatchEvent:s,events:c,eventPrefix:l}=Object.assign({},d,t),u=e=>{let c=t?.element||a.element;if(!(e.target instanceof Node&&c.contains(e.target)||!i(c)&&o)&&(a.clickOutside&&a.clickOutside(e),s)){let t=r(n(`click:outside`,a,l),e,{controller:a});c.dispatchEvent(t)}},f=()=>{c?.forEach((e=>{window.addEventListener(e,u,!0)}))},p=()=>{c?.forEach((e=>{window.removeEventListener(e,u,!0)}))},m=a.disconnect.bind(a);return Object.assign(a,{disconnect(){p(),m()}}),f(),[f,p]};class ClickOutsideComposableController extends e{}class ClickOutsideController extends ClickOutsideComposableController{constructor(e){super(e),requestAnimationFrame((()=>{let[e,t]=f(this,this.options);Object.assign(this,{observe:e,unobserve:t})}))}}class DebounceController extends e{}DebounceController.debounces=[];const p=200,m=(e,t=200,{leading:n=!1,trailing:r=!0}={})=>{let i=null,a=!1;return function(){let o=Array.from(arguments),s=this,c=o.map((e=>e instanceof Event?e.params:void 0)),l=o.map((e=>e instanceof Event?e.currentTarget:void 0)),u=()=>(o.forEach(((e,t)=>{e instanceof Event&&(e.params=c[t],Object.defineProperty(e,"currentTarget",{configurable:!0,value:l[t]}))})),e.apply(s,o)),d=i===null;i&&clearTimeout(i),n&&d?(u(),a=!1):a=!0,i=setTimeout((()=>{i=null,r&&a&&(a=!1,u())}),t)}},h=(e,t)=>{let n=e;n.constructor.debounces.forEach((e=>{if(typeof e==`string`&&(n[e]=m(n[e],t?.wait,{leading:t?.leading,trailing:t?.trailing})),typeof e==`object`){let{name:r,wait:i,leading:a,trailing:o}=e;if(!r)return;n[r]=m(n[r],i??t?.wait,{leading:a??t?.leading,trailing:o??t?.trailing})}}))};class UseHover extends StimulusUse{constructor(e,t={}){super(e,t),this.observe=()=>{this.targetElement.addEventListener(`mouseenter`,this.onEnter),this.targetElement.addEventListener(`mouseleave`,this.onLeave)},this.unobserve=()=>{this.targetElement.removeEventListener(`mouseenter`,this.onEnter),this.targetElement.removeEventListener(`mouseleave`,this.onLeave)},this.onEnter=e=>{this.call(`mouseEnter`,e),this.log(`mouseEnter`,{hover:!0}),this.dispatch(`mouseEnter`,{hover:!1})},this.onLeave=e=>{this.call(`mouseLeave`,e),this.log(`mouseLeave`,{hover:!1}),this.dispatch(`mouseLeave`,{hover:!1})},this.controller=e,this.enhanceController(),this.observe()}enhanceController(){let e=this.controller.disconnect.bind(this.controller),t=()=>{this.unobserve(),e()};Object.assign(this.controller,{disconnect:t})}}const g=(e,t={})=>{let n=new UseHover(e,t);return[n.observe,n.unobserve]};class HoverComposableController extends e{}class HoverController extends HoverComposableController{constructor(e){super(e),requestAnimationFrame((()=>{let[e,t]=g(this,this.options);Object.assign(this,{observe:e,unobserve:t})}))}}const _=[`mousemove`,`mousedown`,`resize`,`keydown`,`touchstart`,`wheel`],v=6e4,y={ms:6e4,initialState:!1,events:_,dispatchEvent:!0,eventPrefix:!0},b=(e,i={})=>{let a=e,{ms:o,initialState:s,events:c,dispatchEvent:l,eventPrefix:u}=Object.assign({},y,i),d=s,f=setTimeout((()=>{d=!0,p()}),o),p=e=>{let i=n(`away`,a,u);if(a.isIdle=!0,t(a,`away`).call(a,e),l){let t=r(i,e||null,{controller:a});a.element.dispatchEvent(t)}},m=e=>{let i=n(`back`,a,u);if(a.isIdle=!1,t(a,`back`).call(a,e),l){let t=r(i,e||null,{controller:a});a.element.dispatchEvent(t)}},h=e=>{d&&m(e),d=!1,clearTimeout(f),f=setTimeout((()=>{d=!0,p(e)}),o)},g=e=>{document.hidden||h(e)};d?p():m();let _=a.disconnect.bind(a),v=()=>{c.forEach((e=>{window.addEventListener(e,h)})),document.addEventListener(`visibilitychange`,g)},b=()=>{clearTimeout(f),c.forEach((e=>{window.removeEventListener(e,h)})),document.removeEventListener(`visibilitychange`,g)};return Object.assign(a,{disconnect(){b(),_()}}),v(),[v,b]};class IdleComposableController extends e{constructor(){super(...arguments),this.isIdle=!1}}class IdleController extends IdleComposableController{constructor(e){super(e),requestAnimationFrame((()=>{let[e,t]=b(this,this.options);Object.assign(this,{observe:e,unobserve:t})}))}}const x={dispatchEvent:!0,eventPrefix:!0,visibleAttribute:`isVisible`},S=(e,i={})=>{let a=e,{dispatchEvent:o,eventPrefix:s,visibleAttribute:c}=Object.assign({},x,i),l=i?.element||a.element;a.intersectionElements||(a.intersectionElements=[]),a.intersectionElements.push(l);let u=e=>{let[t]=e;t.isIntersecting?f(t):l.hasAttribute(c)&&p(t)},d=new IntersectionObserver(u,i),f=e=>{if(l.setAttribute(c,`true`),t(a,`appear`).call(a,e,d),o){let t=r(n(`appear`,a,s),null,{controller:a,entry:e,observer:d});l.dispatchEvent(t)}},p=e=>{if(l.removeAttribute(c),t(a,`disappear`).call(a,e,d),o){let t=r(n(`disappear`,a,s),null,{controller:a,entry:e,observer:d});l.dispatchEvent(t)}},m=a.disconnect.bind(a),h=()=>{_(),m()},g=()=>{d.observe(l)},_=()=>{d.unobserve(l)},v=()=>a.intersectionElements.filter((e=>e.hasAttribute(c))).length===0,y=()=>a.intersectionElements.filter((e=>e.hasAttribute(c))).length===1,b=()=>a.intersectionElements.some((e=>e.hasAttribute(c))),S=()=>a.intersectionElements.every((e=>e.hasAttribute(c)));return Object.assign(a,{isVisible:S,noneVisible:v,oneVisible:y,atLeastOneVisible:b,allVisible:S,disconnect:h}),g(),[g,_]};class IntersectionComposableController extends e{}class IntersectionController extends IntersectionComposableController{constructor(e){super(e),requestAnimationFrame((()=>{let[e,t]=S(this,this.options);Object.assign(this,{observe:e,unobserve:t})}))}}const C=(e,n)=>{let r=t=>{let[n]=t;n.isIntersecting&&!e.isLoaded&&i()},i=n=>{let r=e.data.get(`src`);if(!r)return;let i=e.element;e.isLoading=!0,t(e,`loading`).call(e,r),i.onload=()=>{a(r)},i.src=r},a=n=>{e.isLoading=!1,e.isLoaded=!0,t(e,`loaded`).call(e,n)},o=e.disconnect.bind(e),s=new IntersectionObserver(r,n),c=()=>{s.observe(e.element)},l=()=>{s.unobserve(e.element)};return Object.assign(e,{isVisible:!1,disconnect(){l(),o()}}),c(),[c,l]};class LazyLoadComposableController extends e{constructor(){super(...arguments),this.isLoading=!1,this.isLoaded=!1}}class LazyLoadController extends LazyLoadComposableController{constructor(e){super(e),this.options={rootMargin:`10%`},requestAnimationFrame((()=>{let[e,t]=C(this,this.options);Object.assign(this,{observe:e,unobserve:t})}))}}const w={mediaQueries:{},dispatchEvent:!0,eventPrefix:!0,debug:!1};class UseMatchMedia extends StimulusUse{constructor(e,t={}){if(super(e,t),this.matches=[],this.callback=e=>{let t=Object.keys(this.mediaQueries).find((t=>this.mediaQueries[t]===e.media));if(!t)return;let{media:n,matches:r}=e;this.changed({name:t,media:n,matches:r,event:e})},this.changed=e=>{let{name:t}=e;e.event&&(this.call(a(`${t}_changed`),e),this.dispatch(`${t}:changed`,e),this.log(`media query "${t}" changed`,e)),e.matches?(this.call(a(`is_${t}`),e),this.dispatch(`is:${t}`,e)):(this.call(a(`not_${t}`),e),this.dispatch(`not:${t}`,e))},this.observe=()=>{Object.keys(this.mediaQueries).forEach((e=>{let t=this.mediaQueries[e],n=window.matchMedia(t);n.addListener(this.callback),this.matches.push(n),this.changed({name:e,media:t,matches:n.matches})}))},this.unobserve=()=>{this.matches.forEach((e=>e.removeListener(this.callback)))},this.controller=e,this.mediaQueries=t.mediaQueries??w.mediaQueries,this.dispatchEvent=t.dispatchEvent??w.dispatchEvent,this.eventPrefix=t.eventPrefix??w.eventPrefix,this.debug=t.debug??w.debug,!window.matchMedia){console.error(`window.matchMedia() is not available`);return}this.enhanceController(),this.observe()}enhanceController(){let e=this.controller.disconnect.bind(this.controller),t=()=>{this.unobserve(),e()};Object.assign(this.controller,{disconnect:t})}}const T=(e,t={})=>{let n=new UseMatchMedia(e,t);return[n.observe,n.unobserve]},E=(e,t)=>{let n=Object.getPrototypeOf(e);for(;n&&n!==Object.prototype;){let e=Object.getOwnPropertyDescriptor(n,t);if(e)return e;n=Object.getPrototypeOf(n)}},D=e=>{e.constructor.memos?.forEach((t=>{let n=E(e,t);if(!n||typeof n.get!=`function`)return;let r=n.get;Object.defineProperty(e,t,{configurable:!0,get(){let e=r.call(this);return Object.defineProperty(this,t,{value:e,configurable:!0}),e}})}))},O=(e,t,n)=>{let r=n?`${a(t)}Meta`:a(t);Object.defineProperty(e,r,{get(){return A(k(t))}})};function k(e){let t=document.head.querySelector(`meta[name="${e}"]`);return t&&t.getAttribute(`content`)}function A(e){try{return JSON.parse(e)}catch(t){return e}}const j=(e,t={suffix:!0})=>{let n=e.constructor.metaNames,r=t.suffix;n?.forEach((t=>{O(e,t,r)})),Object.defineProperty(e,"metas",{get(){let e={};return n?.forEach((t=>{let n=A(k(t));n!=null&&(e[a(t)]=n)})),e}})};class UseMutation extends StimulusUse{constructor(e,t={}){super(e,t),this.observe=()=>{try{this.observer.observe(this.targetElement,this.options)}catch(e){this.controller.application.handleError(e,`At a minimum, one of childList, attributes, and/or characterData must be true`,{})}},this.unobserve=()=>{this.observer.disconnect()},this.mutation=e=>{this.call(`mutate`,e),this.log(`mutate`,{entries:e}),this.dispatch(`mutate`,{entries:e})},this.targetElement=t?.element||e.element,this.controller=e,this.options=t,this.observer=new MutationObserver(this.mutation),this.enhanceController(),this.observe()}enhanceController(){let e=this.controller.disconnect.bind(this.controller),t=()=>{this.unobserve(),e()};Object.assign(this.controller,{disconnect:t})}}const M=(e,t={})=>{let n=new UseMutation(e,t);return[n.observe,n.unobserve]};class MutationComposableController extends e{}class MutationController extends MutationComposableController{constructor(e){super(e),requestAnimationFrame((()=>{let[e,t]=M(this,this.options);Object.assign(this,{observe:e,unobserve:t})}))}}const N={dispatchEvent:!0,eventPrefix:!0},P=(e,i={})=>{let a=e,{dispatchEvent:o,eventPrefix:s}=Object.assign({},N,i),c=i?.element||a.element,l=i?.box,u=null,d=e=>{u!==null&&cancelAnimationFrame(u),u=requestAnimationFrame((()=>{u=null;let[i]=e;if(t(a,`resize`).call(a,i.contentRect),o){let e=r(n(`resize`,a,s),null,{controller:a,entry:i});c.dispatchEvent(e)}}))},f=a.disconnect.bind(a),p=new ResizeObserver(d),m=()=>{p.observe(c,l?{box:l}:void 0)},h=()=>{u!==null&&(cancelAnimationFrame(u),u=null),p.unobserve(c)};return Object.assign(a,{disconnect(){h(),f()}}),m(),[m,h]};class ResizeComposableController extends e{}class ResizeController extends ResizeComposableController{constructor(e){super(e),requestAnimationFrame((()=>{let[e,t]=P(this,this.options);Object.assign(this,{observe:e,unobserve:t})}))}}class UseTargetMutation extends StimulusUse{constructor(e,t={}){super(e,t),this.observe=()=>{this.observer.observe(this.targetElement,{subtree:!0,characterData:!0,childList:!0,attributes:!0,attributeOldValue:!0,attributeFilter:[this.targetSelector,this.scopedTargetSelector]})},this.unobserve=()=>{this.observer.disconnect()},this.mutation=e=>{for(let t of e)switch(t.type){case`attributes`:let e=t.target.getAttribute(t.attributeName),n=t.oldValue;if(t.attributeName===this.targetSelector||t.attributeName===this.scopedTargetSelector){let r=this.targetsUsedByThisController(n),i=this.targetsUsedByThisController(e),a=r.filter((e=>!i.includes(e))),o=i.filter((e=>!r.includes(e)));a.forEach((e=>this.targetRemoved(this.stripIdentifierPrefix(e),t.target,`attributeChange`))),o.forEach((e=>this.targetAdded(this.stripIdentifierPrefix(e),t.target,`attributeChange`)))}break;case`characterData`:let r=this.findTargetInAncestry(t.target);if(r==null)return;this.targetsUsedByThisControllerFromNode(r).forEach((e=>{this.targetChanged(this.stripIdentifierPrefix(e),r,`domMutation`)}));break;case`childList`:let{addedNodes:i,removedNodes:a}=t;i.forEach((e=>this.processNodeDOMMutation(e,this.targetAdded))),a.forEach((e=>this.processNodeDOMMutation(e,this.targetRemoved)));break}},this.controller=e,this.options=t,this.targetElement=e.element,this.identifier=e.scope.identifier,this.identifierPrefix=`${this.identifier}.`,this.targetSelector=e.scope.schema.targetAttribute,this.scopedTargetSelector=`data-${this.identifier}-target`,this.targets=t.targets||e.constructor.targets,this.prefixedTargets=this.targets.map((e=>`${this.identifierPrefix}${e}`)),this.observer=new MutationObserver(this.mutation),this.enhanceController(),this.observe()}processNodeDOMMutation(e,t){let n=e,r=t,i=[];n.nodeName==`#text`||this.targetsUsedByThisControllerFromNode(n).length==0?(r=this.targetChanged,n=this.findTargetInAncestry(e)):i=this.targetsUsedByThisControllerFromNode(n),n!=null&&(i.length==0&&(i=this.targetsUsedByThisControllerFromNode(n)),i.forEach((e=>{r.call(this,this.stripIdentifierPrefix(e),n,`domMutation`)})))}findTargetInAncestry(e){let t=e,n=[];for(t.nodeName!=`#text`&&(n=this.targetsUsedByThisControllerFromNode(t));t.parentNode!==null&&t.parentNode!=this.targetElement&&n.length==0;)if(t=t.parentNode,t.nodeName!==`#text`&&this.targetsUsedByThisControllerFromNode(t).length>0)return t;return t.nodeName==`#text`||t.parentNode==null?null:t.parentNode==this.targetElement&&this.targetsUsedByThisControllerFromNode(t).length>0?t:null}targetAdded(e,n,r){let i=`${e}TargetAdded`;this.controller[i]&&t(this.controller,i).call(this.controller,n),this.warn("`[target]TargetAdded` is deprecated. Please use the built-in `[target]TargetConnected()` function from Stimulus."),this.log(`targetAdded`,{target:e,node:n,trigger:r})}targetRemoved(e,n,r){let i=`${e}TargetRemoved`;this.controller[i]&&t(this.controller,i).call(this.controller,n),this.warn("`[target]TargetRemoved` is deprecated. Please use the built-in `[target]TargetDisconnected()` function from Stimulus."),this.log(`targetRemoved`,{target:e,node:n,trigger:r})}targetChanged(e,n,r){let i=`${e}TargetChanged`;this.controller[i]&&t(this.controller,i).call(this.controller,n),this.log(`targetChanged`,{target:e,node:n,trigger:r})}targetsUsedByThisControllerFromNode(e){if(e.nodeName==`#text`||e.nodeName==`#comment`)return[];let t=e;return this.targetsUsedByThisController(t.getAttribute(this.scopedTargetSelector)||t.getAttribute(this.targetSelector))}targetsUsedByThisController(e){e=e||``;let t=this.stripIdentifierPrefix(e).split(` `);return this.targets.filter((e=>t.indexOf(e)!==-1))}stripIdentifierPrefix(e){return e.replace(new RegExp(this.identifierPrefix,`g`),``)}enhanceController(){let e=this.controller.disconnect.bind(this.controller),t=()=>{this.unobserve(),e()};Object.assign(this.controller,{disconnect:t})}}const F=(e,t={})=>{let n=new UseTargetMutation(e,t);return n.warn("`[target]TargetAdded` and `[target]TargetRemoved` are deprecated. Please use the built-in `[target]TargetConnected()` and `[target]TargetDisconnected()` functions from Stimulus."),[n.observe,n.unobserve]};class TargetMutationComposableController extends e{}class TargetMutationController extends TargetMutationComposableController{constructor(e){super(e),requestAnimationFrame((()=>{let[e,t]=F(this,this.options);Object.assign(this,{observe:e,unobserve:t})}))}}class ThrottleController extends e{}ThrottleController.throttles=[];const I=200;function L(e,t=200){let n;return function(){let r=arguments,i=this;n||(n=!0,e.apply(i,r),setTimeout((()=>n=!1),t))}}const R=(e,t={})=>{let n=e;n.constructor.throttles?.forEach((e=>{if(typeof e==`string`&&(n[e]=L(n[e],t?.wait)),typeof e==`object`){let{name:r,wait:i}=e;if(!r)return;n[r]=L(n[r],i||t?.wait)}}))},z={enterFromClass:`enter`,enterActiveClass:`enterStart`,enterToClass:`enterEnd`,leaveFromClass:`leave`,leaveActiveClass:`leaveStart`,leaveToClass:`leaveEnd`},B={transitioned:!1,hiddenClass:`hidden`,preserveOriginalClass:!0,removeToClasses:!0},V=(e,t={})=>{let n=e,r=n.element.dataset.transitionTarget,i;r&&(i=n[`${r}Target`]);let a=t?.element||i||n.element;if(!(a instanceof HTMLElement||a instanceof SVGElement))return;let o=a.dataset,s=parseInt(o.leaveAfter||``)||t.leaveAfter||0,{transitioned:c,hiddenClass:l,preserveOriginalClass:u,removeToClasses:d}=Object.assign({},B,t),f=n.enter?.bind(n),p=n.leave?.bind(n),m=n.toggleTransition?.bind(n);async function h(e){if(n.transitioned)return;n.transitioned=!0,f&&f(e);let r=H(`enterFrom`,t,o),i=H(`enterActive`,t,o),c=H(`enterTo`,t,o),p=H(`leaveTo`,t,o);l&&a.classList.remove(l),d||x(a,p),await v(a,r,i,c,l,u,d),s>0&&setTimeout((()=>{g(e)}),s)}async function g(e){if(!n.transitioned)return;n.transitioned=!1,p&&p(e);let r=H(`leaveFrom`,t,o),i=H(`leaveActive`,t,o),s=H(`leaveTo`,t,o),c=H(`enterTo`,t,o);d||x(a,c),await v(a,r,i,s,l,u,d),l&&a.classList.add(l)}function _(e){m&&m(e),n.transitioned?g():h()}async function v(e,t,n,r,i,a,o){let s=[];a&&(t.forEach((t=>e.classList.contains(t)&&t!==i&&s.push(t))),n.forEach((t=>e.classList.contains(t)&&t!==i&&s.push(t))),r.forEach((t=>e.classList.contains(t)&&t!==i&&s.push(t)))),b(e,t),x(e,s),b(e,n),await W(),x(e,t),b(e,r),await U(e),x(e,n),o&&x(e,r),b(e,s)}function y(){n.transitioned=c,c?(l&&a.classList.remove(l),h()):(l&&a.classList.add(l),g())}function b(e,t){t.length>0&&e.classList.add(...t)}function x(e,t){t.length>0&&e.classList.remove(...t)}return y(),Object.assign(n,{enter:h,leave:g,toggleTransition:_}),[h,g,_]};function H(e,t,n){let r=`transition${e[0].toUpperCase()}${e.substr(1)}`,i=z[e],a=t[e]||n[r]||n[i]||` `;return G(a)?[]:a.split(` `)}async function U(e){return new Promise((t=>{let n=Number(getComputedStyle(e).transitionDuration.split(`,`)[0].replace(`s`,``))*1e3;setTimeout((()=>{t(n)}),n)}))}async function W(){return new Promise((e=>{requestAnimationFrame((()=>{requestAnimationFrame(e)}))}))}function G(e){return e.length===0||!e.trim()}class TransitionComposableController extends e{constructor(){super(...arguments),this.transitioned=!1}}class TransitionController extends TransitionComposableController{constructor(e){super(e),requestAnimationFrame((()=>{V(this,this.options)}))}}class UseVisibility extends StimulusUse{constructor(e,t={}){super(e,t),this.observe=()=>{this.controller.isVisible=!document.hidden,document.addEventListener(`visibilitychange`,this.handleVisibilityChange),this.handleVisibilityChange()},this.unobserve=()=>{document.removeEventListener(`visibilitychange`,this.handleVisibilityChange)},this.becomesInvisible=e=>{this.controller.isVisible=!1,this.call(`invisible`,e),this.log(`invisible`,{isVisible:!1}),this.dispatch(`invisible`,{event:e,isVisible:!1})},this.becomesVisible=e=>{this.controller.isVisible=!0,this.call(`visible`,e),this.log(`visible`,{isVisible:!0}),this.dispatch(`visible`,{event:e,isVisible:!0})},this.handleVisibilityChange=e=>{document.hidden?this.becomesInvisible(e):this.becomesVisible(e)},this.controller=e,this.enhanceController(),this.observe()}enhanceController(){let e=this.controllerDisconnect,t=()=>{this.unobserve(),e()};Object.assign(this.controller,{disconnect:t})}}const K=(e,t={})=>{let n=new UseVisibility(e,t);return[n.observe,n.unobserve]};class VisibilityComposableController extends e{constructor(){super(...arguments),this.isVisible=!1}}class VisibilityController extends VisibilityComposableController{constructor(e){super(e),requestAnimationFrame((()=>{let[e,t]=K(this,this.options);Object.assign(this,{observe:e,unobserve:t})}))}}class UseWindowFocus extends StimulusUse{constructor(e,t={}){super(e,t),this.observe=()=>{document.hasFocus()?this.becomesFocused():this.becomesUnfocused(),this.interval=setInterval((()=>{this.handleWindowFocusChange()}),this.intervalDuration)},this.unobserve=()=>{clearInterval(this.interval)},this.becomesUnfocused=e=>{this.controller.hasFocus=!1,this.call(`unfocus`,e),this.log(`unfocus`,{hasFocus:!1}),this.dispatch(`unfocus`,{event:e,hasFocus:!1})},this.becomesFocused=e=>{this.controller.hasFocus=!0,this.call(`focus`,e),this.log(`focus`,{hasFocus:!0}),this.dispatch(`focus`,{event:e,hasFocus:!0})},this.handleWindowFocusChange=e=>{document.hasFocus()&&!this.controller.hasFocus?this.becomesFocused(e):!document.hasFocus()&&this.controller.hasFocus&&this.becomesUnfocused(e)},this.controller=e,this.intervalDuration=t.interval||200,this.enhanceController(),this.observe()}enhanceController(){let e=this.controllerDisconnect,t=()=>{this.unobserve(),e()};Object.assign(this.controller,{disconnect:t})}}const q=(e,t={})=>{let n=new UseWindowFocus(e,t);return[n.observe,n.unobserve]};class WindowFocusComposableController extends e{constructor(){super(...arguments),this.hasFocus=!1}}class WindowFocusController extends WindowFocusComposableController{constructor(e){super(e),requestAnimationFrame((()=>{let[e,t]=q(this,this.options);Object.assign(this,{observe:e,unobserve:t})}))}}const J=e=>{let n=e,r=e=>{let{innerWidth:r,innerHeight:i}=window,a={height:i||1/0,width:r||1/0,event:e};t(n,`windowResize`).call(n,a)},i=n.disconnect.bind(n),a=()=>{window.addEventListener(`resize`,r),r()},o=()=>{window.removeEventListener(`resize`,r)};return Object.assign(n,{disconnect(){o(),i()}}),a(),[a,o]};class WindowResizeComposableController extends e{}class WindowResizeController extends WindowResizeComposableController{constructor(e){super(e),requestAnimationFrame((()=>{let[e,t]=J(this);Object.assign(this,{observe:e,unobserve:t})}))}}function Y(){throw"[stimulus-use] Notice: The import for `useHotkeys()` has been moved from `stimulus-use` to `stimulus-use/hotkeys`. \nPlease change the import accordingly and add `hotkey-js` as a dependency to your project. \n\nFor more information see: https://stimulus-use.dev/use-hotkeys#importing-the-behavior"}export{ApplicationController,ClickOutsideController,HoverController,IdleController,IntersectionController,LazyLoadController,MutationController,ResizeController,TargetMutationController,TransitionController,UseHover,UseMutation,UseTargetMutation,UseVisibility,UseWindowFocus,VisibilityController,WindowFocusController,WindowResizeController,m as debounce,u as useApplication,f as useClickOutside,h as useDebounce,c as useDispatch,Y as useHotkeys,g as useHover,b as useIdle,S as useIntersection,C as useLazyLoad,T as useMatchMedia,D as useMemo,j as useMeta,M as useMutation,P as useResize,F as useTargetMutation,R as useThrottle,V as useTransition,K as useVisibility,q as useWindowFocus,J as useWindowResize};