From 9ba108b6b069559024e9afaa139faf89cc6cf53e Mon Sep 17 00:00:00 2001 From: Sergei Poljanski Date: Sun, 11 Oct 2026 20:34:24 +0400 Subject: [PATCH] bingo: default to the operational card The card led with construction projects (Sber Balakovo at #1, MOS7, MOS11, Yandex Vladimir-2), so the first thing visitors saw was not what actually runs today. Make the operational-only card the bare URL and move the full list to ?view=all. The variant is noindex with canonical to the bare page, old ?view=operational links still land on the default, and the admin dashboard tile follows the public default. Assisted-by: Claude:opus-5 --- CLAUDE.md | 2 +- asxpio.rb | 2 +- bingo_web.rb | 9 +++++---- lib/bingo.rb | 2 +- test/bingo_test.rb | 23 +++++++++++++++-------- 5 files changed, 23 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 59ba97d..a5d732c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,7 +130,7 @@ A second public site served from the **same process** on `BINGO_HOST` (`datacent - Tables `bingo_datacenters`, `bingo_incidents` (`db/migrations/004_bingo.rb`) in the asxpio DB. `updated_at` is set on save; `renumber!` bumps only moved rows and deleting an incident bumps its datacenter (use `destroy`, not dataset `delete`, so the hook runs). 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". - Status: operational | building | planned. `mw_type` (it | connected | total | design | mva, NULL = not stated) tags the MW figure in the dialog; mva swaps the unit. -- 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. +- Views: the bare locale URL is the **operational** card (default, indexed); `?view=all` adds construction/planned projects in the same ranking with lines recomputed, and is noindex with canonical to the bare URL. The admin dashboard tile shows the operational card too. - 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), `-1200.webp` (dialog, JSON-LD) and `-og.jpg` (1200×630 centre crop, og:image); `photo_variants`/`photo_og` record success (rows without `photo_og` use the 1200 WebP as og:image) 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`, `/sitemap.xml` (hreflang alternates, `lastmod` = max `updated_at`), and favicon/apple-touch-icon routes serving `assets/bingo/`. The h1 carries a per-locale `tagline`; `/en/` 301s to `/en`. Per-locale `seo_title`/`seo_desc` with the live score, OG/Twitter tags (og:image = latest hit's photo, alt = its name and city), JSON-LD WebSite + ItemList of Places (`info_url` as `subjectOf`, incidents as `event`). HSTS via Rack::Protection. Canonical/hreflang always use `BINGO_HOST`, so `BINGO_ALT_HOST` doesn't compete. diff --git a/asxpio.rb b/asxpio.rb index a3a3c06..06f3b37 100644 --- a/asxpio.rb +++ b/asxpio.rb @@ -162,7 +162,7 @@ class AsxpioWeb < Sinatra::Base } end if can?('bingo', 'read') - board = Bingo::Board.load + board = Bingo::Board.load('operational') # same card as the public default @bingo = { cells: board.cells.size, hit: board.hit.count(true), diff --git a/bingo_web.rb b/bingo_web.rb index 7aecebc..cdb3761 100644 --- a/bingo_web.rb +++ b/bingo_web.rb @@ -23,9 +23,10 @@ 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. + # Path for a locale/view combination; the default view (operational) + # keeps a bare URL, the full list is ?view=all. def card_path(locale = @locale, view = @view) - view == 'operational' ? "/#{locale}?view=operational" : "/#{locale}" + view == 'all' ? "/#{locale}?view=all" : "/#{locale}" end def dc_path(dc, locale = @locale) = "/#{locale}/dc/#{dc.slug}" @@ -165,11 +166,11 @@ class BingoWeb < Sinatra::Base get %r{/(ru|en|uk)} do |locale| @locale = locale - @view = Bingo::VIEWS.include?(params[:view]) ? params[:view] : 'all' + @view = params[:view] == 'all' ? 'all' : Bingo::VIEWS.first @board = Bingo::Board.load(@view) @title = t(:seo_title) @desc = format(t(:seo_desc), hit: @board.hit.count(true), total: @board.cells.size) - @noindex = @board.operational_only? + @noindex = !@board.operational_only? # ?view=all is a variant of the bare page @path_for = ->(l) { "/#{l}" } @x_default = '/' @og = og_dc(@board) diff --git a/lib/bingo.rb b/lib/bingo.rb index 0dcfe8c..4629c7c 100644 --- a/lib/bingo.rb +++ b/lib/bingo.rb @@ -178,7 +178,7 @@ module Bingo end # Board state for rendering: ordered cells, hits and completed lines. - VIEWS = %w[all operational].freeze + VIEWS = %w[operational all].freeze # first is the default (bare URL) # The card: the first SIZE*SIZE datacenters by position, optionally only # operational ones (same ranking, construction projects skipped). diff --git a/test/bingo_test.rb b/test/bingo_test.rb index 6aa2941..1953074 100644 --- a/test/bingo_test.rb +++ b/test/bingo_test.rb @@ -132,21 +132,28 @@ class BingoTest < Minitest::Test assert_equal 1, Bingo::Board.load('operational').wins.size end - def test_view_toggle_page - get '/ru?view=operational' + def test_default_view_is_operational_and_all_is_a_noindex_variant + @dcs[0].update(status: 'building') + get '/ru' assert last_response.ok? + refute_includes last_response.body, 'ЦОД 1<' # construction project hidden by default + assert_includes last_response.body, 'index, follow, max-image-preview' + assert_includes last_response.body, '' + get '/ru?view=all' + assert last_response.ok? + assert_includes last_response.body, 'ЦОД 1<' 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, '' # followed internal link + assert_includes last_response.body, 'href="/en?view=all"' # language links keep the view + assert_includes last_response.body, '' assert_includes last_response.body, 'rel="canonical" href="https://example.org/ru"' - get '/ru?view=bogus' + get '/ru?view=operational' # old links still land on the default assert_includes last_response.body, 'index, follow, max-image-preview' end def test_capacity_type_and_planned_status @dcs[0].update(mw: '64', mw_type: 'design', status: 'planned') @dcs[1].update(mw: '17', mw_type: 'mva') - get '/en' + get '/en?view=all' # planned sites only appear in the full list body = last_response.body assert_includes body, '64 MW · design' # dialog assert_includes body, '#1 · 64 MW' # cell: no tag @@ -204,9 +211,9 @@ class BingoTest < Minitest::Test end def test_trailing_slash_redirects - get '/en/', view: 'operational' + get '/en/', view: 'all' assert_equal 301, last_response.status - assert_equal 'http://example.org/en?view=operational', last_response.location + assert_equal 'http://example.org/en?view=all', last_response.location end def test_icons