From e5bd1c1ac7294dc88007bf760e8d2f98b24a46d5 Mon Sep 17 00:00:00 2001 From: Sergei Poljanski Date: Sat, 10 Oct 2026 06:59:04 +0400 Subject: [PATCH] bingo: planned status and capacity type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "MW" on the card mixed IT load, connected power, total site power, design capacity and even MVA (Oxygen, Sber Yuzhny Port). Record which one a figure is in a new mw_type column; the dialog tags the number ("64 MW · design") and MVA replaces the unit, while cells keep the bare figure to stay readable. Add a "planned" status for projects not yet under construction; like "building", it is left out of the operational view. Assisted-by: Claude:opus-5 --- CLAUDE.md | 1 + bingo_web.rb | 13 +++++++++++-- db/migrations/006_bingo_mw_type.rb | 7 +++++++ lib/bingo.rb | 11 +++++++++++ lib/bingo_admin.rb | 5 +++-- test/bingo_test.rb | 24 ++++++++++++++++++++++++ views/admin/bingo/edit.erb | 9 ++++++++- views/admin/bingo/index.erb | 2 +- views/bingo/index.erb | 6 +++--- 9 files changed, 69 insertions(+), 9 deletions(-) create mode 100644 db/migrations/006_bingo_mw_type.rb diff --git a/CLAUDE.md b/CLAUDE.md index 805e362..7e782c5 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". +- 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. - 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. diff --git a/bingo_web.rb b/bingo_web.rb index 42d4280..ee1369a 100644 --- a/bingo_web.rb +++ b/bingo_web.rb @@ -57,8 +57,17 @@ class BingoWeb < Sinatra::Base end def fmt_date(d) = d.strftime('%d.%m.%Y') - def mw(dc) - dc.mw.to_s.strip.empty? ? nil : "#{dc.mw} #{t(:mw)}" + # "64 MW", "64 MW · design", "17 MVA" (MVA is a unit, not a tag). + def mw(dc, tag: true) + return if dc.mw.to_s.strip.empty? + return "#{dc.mw} #{t(:mva)}" if dc.mw_type == 'mva' + tag &&= t(:mw_types)[dc.mw_type] + ["#{dc.mw} #{t(:mw)}", tag || nil].compact.join(' · ') + end + + # Badge for anything not yet in service. + def status_label(dc) + { 'building' => t(:building), 'planned' => t(:planned) }[dc.status] end def preferred_locale diff --git a/db/migrations/006_bingo_mw_type.rb b/db/migrations/006_bingo_mw_type.rb new file mode 100644 index 0000000..ac5f303 --- /dev/null +++ b/db/migrations/006_bingo_mw_type.rb @@ -0,0 +1,7 @@ +Sequel.migration do + change do + # What the mw figure measures: it | connected | total | design | mva. + # NULL = not stated by the source. + add_column :bingo_datacenters, :mw_type, String + end +end diff --git a/lib/bingo.rb b/lib/bingo.rb index ae1f9ae..2b64c76 100644 --- a/lib/bingo.rb +++ b/lib/bingo.rb @@ -12,6 +12,8 @@ module Bingo KINDS = %w[strike fire outage flood cooling other].freeze ICONS = { 'strike' => '💥', 'fire' => '🔥', 'outage' => '⚡', 'flood' => '💧', 'cooling' => '🌡️', 'other' => '⚠️' }.freeze SIZE = 5 + STATUSES = %w[operational building planned].freeze + MW_TYPES = %w[it connected total design mva].freeze # Every row, column and both diagonals, as 0-based cell indices. LINES = ( @@ -30,6 +32,8 @@ module Bingo crossed: 'Зачёркнуто', lines: 'линий', updated: 'обновлено', log: 'Журнал инцидентов', empty: 'Пока всё работает. Пока.', bingo: 'БИНГО!', bingo_n: 'БИНГО ×%d!', full: 'ПОЛНАЯ КАРТА. Интернета больше нет.', + planned: 'планируется', mva: 'МВА', + mw_types: { 'it' => 'ИТ', 'connected' => 'подв.', 'total' => 'общ.', 'design' => 'проект' }, building: 'строится', mw: 'МВт', since: 'с %d г.', photo: 'Фото', source: 'Источник', close: 'Закрыть', no_incidents: 'Инцидентов не зафиксировано.', footer: 'Данные из открытых источников (2025–2026). Клетка зачёркивается только по публичному источнику.', @@ -46,6 +50,8 @@ module Bingo crossed: 'Crossed out', lines: 'lines', updated: 'updated', log: 'Incident log', empty: 'Everything is up. For now.', bingo: 'BINGO!', bingo_n: 'BINGO ×%d!', full: 'FULL CARD. There is no more internet.', + planned: 'planned', mva: 'MVA', + mw_types: { 'it' => 'IT', 'connected' => 'connected', 'total' => 'total', 'design' => 'design' }, building: 'under construction', mw: 'MW', since: 'since %d', photo: 'Photo', source: 'Source', close: 'Close', no_incidents: 'No incidents recorded.', footer: 'Open-source data (2025–2026). A cell is crossed out only with a public source.', @@ -62,6 +68,8 @@ module Bingo crossed: 'Закреслено', lines: 'ліній', updated: 'оновлено', log: 'Журнал інцидентів', empty: 'Поки що все працює. Поки що.', bingo: 'БІНГО!', bingo_n: 'БІНГО ×%d!', full: 'ПОВНА КАРТКА. Інтернету більше немає.', + planned: 'планується', mva: 'МВА', + mw_types: { 'it' => 'ІТ', 'connected' => 'підв.', 'total' => 'заг.', 'design' => 'проєкт' }, building: 'будується', mw: 'МВт', since: 'з %d р.', photo: 'Фото', source: 'Джерело', close: 'Закрити', no_incidents: 'Інцидентів не зафіксовано.', footer: 'Дані з відкритих джерел (2025–2026). Клітинка закреслюється лише за публічним джерелом.', @@ -88,6 +96,9 @@ module Bingo super end + def operational? = status == 'operational' + def planned? = status == 'planned' + def building? status == 'building' end diff --git a/lib/bingo_admin.rb b/lib/bingo_admin.rb index 9a4b93a..b8341bb 100644 --- a/lib/bingo_admin.rb +++ b/lib/bingo_admin.rb @@ -3,7 +3,7 @@ require 'uri' # /admin/bingo/* on asxp.io: edit the card and record incidents for BingoWeb. # Registered into AsxpioWeb, so AdminAuth (HTTP Basic) and CSRF apply. module BingoAdmin - DC_TEXT = %w[name_ru name_en name_uk city_ru city_en city_uk operator mw status + DC_TEXT = %w[name_ru name_en name_uk city_ru city_en city_uk operator mw mw_type status photo_credit photo_source_url info_url].freeze module Helpers @@ -23,7 +23,8 @@ module BingoAdmin def assign_dc(dc) DC_TEXT.each { |f| dc[f.to_sym] = blank_nil(params[f]) } dc.year = params[:year].to_s =~ /\A\d{4}\z/ ? params[:year].to_i : nil - dc.status = 'operational' unless %w[operational building].include?(dc.status) + dc.status = 'operational' unless Bingo::STATUSES.include?(dc.status) + dc.mw_type = nil unless Bingo::MW_TYPES.include?(dc.mw_type) errs = [] errs << 'Name (ru), city (ru) and operator are required' if [dc.name_ru, dc.city_ru, dc.operator].any?(&:nil?) errs << 'URLs must be http(s)' unless [dc.photo_source_url, dc.info_url].all? { |u| http_url?(u.to_s) } diff --git a/test/bingo_test.rb b/test/bingo_test.rb index d9169a7..853978c 100644 --- a/test/bingo_test.rb +++ b/test/bingo_test.rb @@ -144,6 +144,30 @@ class BingoTest < Minitest::Test 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' + body = last_response.body + assert_includes body, '64 MW · design' # dialog + assert_includes body, '#1 · 64 MW' # cell: no tag + assert_includes body, '17 MVA' + assert_includes body, '#2 · 17 MVA' # MVA replaces the unit in cells too + assert_includes body, '>planned<' + refute_includes Bingo::Board.load('operational').cells.map(&:slug), 'dc-1' + end + + def test_admin_saves_capacity_type_and_planned + @app = AsxpioWeb + token = admin_token("/admin/bingo/dc/#{@dcs[0].id}") + post "/admin/bingo/dc/#{@dcs[0].id}", dc_params(authenticity_token: token, name_ru: 'ЦОД 1', status: 'planned', mw_type: 'connected'), admin_env + @dcs[0].refresh + assert @dcs[0].planned? + assert_equal 'connected', @dcs[0].mw_type + post "/admin/bingo/dc/#{@dcs[0].id}", dc_params(authenticity_token: token, name_ru: 'ЦОД 1', mw_type: 'bogus'), admin_env + assert_nil @dcs[0].refresh.mw_type + end + def test_robots_and_sitemap_are_the_bingos_own get '/robots.txt' assert last_response.ok? diff --git a/views/admin/bingo/edit.erb b/views/admin/bingo/edit.erb index 7e7f542..f1ce889 100644 --- a/views/admin/bingo/edit.erb +++ b/views/admin/bingo/edit.erb @@ -41,10 +41,17 @@ <% end %>
+
+ + +
diff --git a/views/admin/bingo/index.erb b/views/admin/bingo/index.erb index 95ecc92..04c76e2 100644 --- a/views/admin/bingo/index.erb +++ b/views/admin/bingo/index.erb @@ -97,7 +97,7 @@ <% if dc.photo_file %><% else %>—<% end %> <%= dc.name_ru %>
<%= dc.city_ru %> <%= dc.operator %> - <%= dc.mw %> + <%= dc.mw %><%= " #{dc.mw_type}" if dc.mw_type %> <%= dc.status %> <%= dc.incidents.size %> edit diff --git a/views/bingo/index.erb b/views/bingo/index.erb index 3f11d29..894a264 100644 --- a/views/bingo/index.erb +++ b/views/bingo/index.erb @@ -38,8 +38,8 @@ aria-label="<%= dc.l(:name, @locale) %><%= " — #{kind_label(dc.incidents.first.kind)}" if b.hit[idx] %>"> <% if photo %>= 10 %>><% end %>
-
#<%= idx + 1 %><%= " · #{mw(dc)}" if mw(dc) %>
- <% if dc.building? %>
<%= t(:building) %>
<% end %> +
#<%= idx + 1 %><%= " · #{mw(dc, tag: false)}" if mw(dc) %>
+ <% if status_label(dc) %>
<%= status_label(dc) %>
<% end %>
<%= dc.l(:name, @locale) %>
@@ -81,7 +81,7 @@

<%= dc.l(:name, @locale) %>

- <%= [dc.operator, mw(dc), dc.l(:city, @locale), dc.building? ? t(:building) : (dc.year && format(t(:since), dc.year))].compact.join(' · ') %> + <%= [dc.operator, mw(dc), dc.l(:city, @locale), status_label(dc) || (dc.year && format(t(:since), dc.year))].compact.join(' · ') %>
<% if dc.photo_credit || dc.info_url %>