diff --git a/CLAUDE.md b/CLAUDE.md index dc4ce38..805e362 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,6 +123,7 @@ A second public site served from the **same process** on `BINGO_HOST` (`datacent - Locales `ru` / `en` / `uk` at `/ru`, `/en`, `/uk`; `/` redirects by `Accept-Language` (fallback `en`). Per-locale DB columns (`name_*`, `city_*`, `title_*`); `ru` is required, others fall back to it. UI strings in `Bingo::T` (`lib/bingo.rb`). - Tables `bingo_datacenters`, `bingo_incidents` (`db/migrations/004_bingo.rb`) in the asxpio DB. First boot seeds from `db/bingo_seed.json` (verified 2026-10-10, sources in `info_url`) only if the table is empty — after that, edit via admin, not the JSON. - Admin: `/admin/bingo` on asxp.io (`lib/bingo_admin.rb`, registered into `AsxpioWeb`, so HTTP Basic + CSRF apply). Record/delete incidents; add, edit, delete DCs (delete cascades to incidents); upload a photo or import it from a URL; bulk import missing / re-import all photos from `photo_source_url`; "Sort by MW". +- Views: `?view=operational` builds the card from operational rows only (same ranking, construction projects skipped, lines recomputed); it is noindex with canonical to the bare locale URL. - Ordering: `position` is dense 1..N (`Bingo.place!` inserts and shifts, `renumber!` after deletes). The card shows positions 1–25; anything beyond is reserve. Slug is fixed at creation (it names photo objects). - Photos: stored in the existing bucket under `bingo/-.` (no storage-repo change), proxied by `BingoWeb` at `/p/:file` with immutable caching (new upload = new key; old objects are kept). Type sniffed from magic bytes (JPEG/PNG/WebP), 8 MB cap, metadata stripped (GPS!). URL import is https-only, admin-only. On store, ImageMagick (`convert`, installed in the Dockerfile) derives `-480.webp` (card cells) and `-1200.webp` (dialog, og:image); `photo_variants` records success and the page falls back to the original without them. "Rebuild resized photos" in admin re-derives from stored originals. - SEO: `BingoWeb` has `static` off (public/ is asxp.io's), its own `/robots.txt` and `/sitemap.xml` (hreflang alternates), per-locale `seo_title`/`seo_desc` with the live score, OG/Twitter tags (og:image = latest hit's photo), JSON-LD WebSite + ItemList. Canonical/hreflang always use `BINGO_HOST`, so `BINGO_ALT_HOST` doesn't compete. diff --git a/bingo_web.rb b/bingo_web.rb index d04cdf4..42d4280 100644 --- a/bingo_web.rb +++ b/bingo_web.rb @@ -21,6 +21,11 @@ class BingoWeb < Sinatra::Base def kind_label(kind) = Bingo::T[@locale][:kinds][kind] || Bingo::T[@locale][:kinds]['other'] def kind_icon(kind) = Bingo::ICONS[kind] || Bingo::ICONS['other'] def photo_path(dc, width = nil) = dc.photo_file(width) && "/p/#{dc.photo_file(width)}" + # Path for a locale/view combination; the default view keeps a bare URL. + def card_path(locale = @locale, view = @view) + view == 'operational' ? "/#{locale}?view=operational" : "/#{locale}" + end + def base_url = "https://#{ENV.fetch('BINGO_HOST', request.host)}" # WebSite + ItemList of the card's datacenters, for rich results. @@ -85,7 +90,8 @@ class BingoWeb < Sinatra::Base get %r{/(ru|en|uk)/?} do |locale| @locale = locale - @board = Bingo::Board.load + @view = Bingo::VIEWS.include?(params[:view]) ? params[:view] : 'all' + @board = Bingo::Board.load(@view) cache_control :public, max_age: 60 erb :index end diff --git a/lib/bingo.rb b/lib/bingo.rb index 58367cf..ae1f9ae 100644 --- a/lib/bingo.rb +++ b/lib/bingo.rb @@ -34,7 +34,8 @@ module Bingo close: 'Закрыть', no_incidents: 'Инцидентов не зафиксировано.', footer: 'Данные из открытых источников (2025–2026). Клетка зачёркивается только по публичному источнику.', kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожар', 'outage' => 'Отключение', 'flood' => 'Затопление', 'cooling' => 'Перегрев', 'other' => 'Инцидент' }, - letters: %w[Б И Н Г О] + letters: %w[Б И Н Г О], + view_all: 'Все', view_operational: 'Только действующие', view_label: 'Какие ЦОДы показывать' }, 'en' => { seo_title: 'Datacenter Bingo — strikes, fires and outages at Russian datacenters', @@ -49,7 +50,8 @@ module Bingo close: 'Close', no_incidents: 'No incidents recorded.', footer: 'Open-source data (2025–2026). A cell is crossed out only with a public source.', kinds: { 'strike' => 'Drone strike', 'fire' => 'Fire', 'outage' => 'Outage', 'flood' => 'Flood', 'cooling' => 'Overheating', 'other' => 'Incident' }, - letters: %w[B I N G O] + letters: %w[B I N G O], + view_all: 'All', view_operational: 'Operational only', view_label: 'Which datacenters to show' }, 'uk' => { seo_title: 'Бінго ЦОДів — атаки, пожежі та аварії дата-центрів Росії', @@ -64,7 +66,8 @@ module Bingo close: 'Закрити', no_incidents: 'Інцидентів не зафіксовано.', footer: 'Дані з відкритих джерел (2025–2026). Клітинка закреслюється лише за публічним джерелом.', kinds: { 'strike' => 'Атака БПЛА', 'fire' => 'Пожежа', 'outage' => 'Відключення', 'flood' => 'Затоплення', 'cooling' => 'Перегрів', 'other' => 'Інцидент' }, - letters: %w[Б І Н Г О] + letters: %w[Б І Н Г О], + view_all: 'Усі', view_operational: 'Лише діючі', view_label: 'Які ЦОДи показувати' } }.freeze @@ -109,16 +112,24 @@ module Bingo end # Board state for rendering: ordered cells, hits and completed lines. - Board = Struct.new(:cells, :hit, :wins, :incidents, :updated_on) do - def self.load - cells = Datacenter.order(:position, :id).limit(SIZE * SIZE).eager(:incidents).all + VIEWS = %w[all operational].freeze + + # The card: the first SIZE*SIZE datacenters by position, optionally only + # operational ones (same ranking, construction projects skipped). + Board = Struct.new(:cells, :hit, :wins, :incidents, :updated_on, :view) do + def self.load(view = 'all') + scope = Datacenter.order(:position, :id) + scope = scope.where(status: 'operational') if view == 'operational' + cells = scope.limit(SIZE * SIZE).eager(:incidents).all hit = cells.map { |dc| dc.incidents.any? } wins = LINES.select { |line| line.all? { |i| hit[i] } } incidents = cells.flat_map(&:incidents).sort_by { |i| [i.occurred_on, i.id] }.reverse updated = [Datacenter.max(:created_at), Incident.max(:created_at)].compact.max - new(cells, hit, wins, incidents, updated) + new(cells, hit, wins, incidents, updated, view) end + def operational_only? = view == 'operational' + def win_cells @win_cells ||= wins.flatten.to_set end diff --git a/test/bingo_test.rb b/test/bingo_test.rb index bdd5ce2..d9169a7 100644 --- a/test/bingo_test.rb +++ b/test/bingo_test.rb @@ -114,6 +114,36 @@ class BingoTest < Minitest::Test assert_equal out.bytesize - 8, out.byteslice(4, 4).unpack1('V') end + def test_operational_view_skips_construction_projects + @dcs[0].update(status: 'building') + Bingo::Datacenter.create(slug: 'dc-26', position: 26, name_ru: 'ЦОД 26', city_ru: 'Москва', operator: 'Op', mw: '1') + all = Bingo::Board.load + op = Bingo::Board.load('operational') + assert_includes all.cells.map(&:slug), 'dc-1' + refute_includes op.cells.map(&:slug), 'dc-1' + assert_equal 'dc-26', op.cells.last.slug # reserve moves up to fill the card + assert_equal 25, op.cells.size + end + + def test_operational_view_recomputes_lines + (1..4).each { |i| hit!(i) } # row 1 minus dc-1 + @dcs[0].update(status: 'building') + Bingo::Datacenter.create(slug: 'dc-26', position: 26, name_ru: 'ЦОД 26', city_ru: 'Москва', operator: 'Op') + assert_equal 0, Bingo::Board.load.wins.size # dc-1 not hit + hit!(5) # dc-6 now sits at index 4 in operational view + assert_equal 1, Bingo::Board.load('operational').wins.size + end + + def test_view_toggle_page + get '/ru?view=operational' + assert last_response.ok? + assert_includes last_response.body, 'noindex, follow' + assert_includes last_response.body, 'href="/en?view=operational"' # language links keep the view + assert_includes last_response.body, 'rel="canonical" href="https://example.org/ru"' + get '/ru?view=bogus' + assert_includes last_response.body, 'index, follow, max-image-preview' + end + def test_robots_and_sitemap_are_the_bingos_own get '/robots.txt' assert last_response.ok? diff --git a/views/bingo/index.erb b/views/bingo/index.erb index e8c2e6c..3f11d29 100644 --- a/views/bingo/index.erb +++ b/views/bingo/index.erb @@ -2,11 +2,18 @@

<%= t(:lead) %> <%= t(:accent) %>

- +
+ + +

<%= t(:desc) %>

diff --git a/views/bingo/layout.erb b/views/bingo/layout.erb index c3d3a76..fa14265 100644 --- a/views/bingo/layout.erb +++ b/views/bingo/layout.erb @@ -8,7 +8,7 @@ <%= t(:seo_title) %> - + <% Bingo::LOCALES.each do |l| %> @@ -58,6 +58,11 @@ header { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 8px 16px; margin-bottom: 16px; } h1 { margin: 0; font-size: clamp(28px, 6vw, 44px); letter-spacing: -.02em; } h1 span { color: var(--hit); } + .switches { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } + .toggle { display: flex; border: 1px solid var(--line); border-radius: 6px; overflow: hidden; } + .toggle a { padding: 2px 10px; font-size: 13px; text-decoration: none; } + .toggle a + a { border-left: 1px solid var(--line); } + .toggle a[aria-current] { background: var(--hit); color: var(--bg); } .langs { display: flex; gap: 4px; } .langs a { padding: 2px 8px; border: 1px solid var(--line); border-radius: 6px; text-decoration: none; font-size: 13px; text-transform: uppercase; } .langs a[aria-current] { background: var(--ink); color: var(--bg); border-color: var(--ink); }