From 6f37e537cac0c912db528b750c7cb261471cb644 Mon Sep 17 00:00:00 2001 From: Sergei Poljanski Date: Sat, 10 Oct 2026 09:30:37 +0400 Subject: [PATCH] bingo: lastmod from the last edit, not the last insert The "updated" date and the sitemap lastmod came from max(created_at), so editing a datacenter, fixing an incident title or deleting an incident left them stale. Google stops trusting lastmod once it is seen to be wrong. Migration 014 adds updated_at (backfilled from created_at). Saves set it, renumbering bumps only the rows whose position actually moved, and deleting an incident bumps its datacenter. The admin delete now goes through the model so the hook runs. The sitemap gives lastmod as a full timestamp and drops changefreq, which Google ignores. Assisted-by: Claude:opus-5 --- db/migrations/014_bingo_updated_at.rb | 13 +++++++++++++ lib/bingo.rb | 27 +++++++++++++++++++++++---- lib/bingo_admin.rb | 2 +- test/bingo_test.rb | 15 +++++++++++++++ views/bingo/sitemap.erb | 4 ++-- 5 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 db/migrations/014_bingo_updated_at.rb diff --git a/db/migrations/014_bingo_updated_at.rb b/db/migrations/014_bingo_updated_at.rb new file mode 100644 index 0000000..2df7048 --- /dev/null +++ b/db/migrations/014_bingo_updated_at.rb @@ -0,0 +1,13 @@ +Sequel.migration do + up do + # Last change to the row, for the "updated" date and sitemap lastmod. + %i[bingo_datacenters bingo_incidents].each do |t| + add_column t, :updated_at, DateTime + from(t).update(updated_at: :created_at) + end + end + + down do + %i[bingo_datacenters bingo_incidents].each { |t| drop_column t, :updated_at } + end +end diff --git a/lib/bingo.rb b/lib/bingo.rb index b1fb328..707e96c 100644 --- a/lib/bingo.rb +++ b/lib/bingo.rb @@ -99,6 +99,11 @@ module Bingo super end + def before_save + self.updated_at = Time.now.utc + super + end + def operational? = status == 'operational' def planned? = status == 'planned' @@ -126,10 +131,21 @@ module Bingo include Localized many_to_one :datacenter, class: 'Bingo::Datacenter' + # A removed incident changes its datacenter's cell. + def after_destroy + super + Datacenter.where(id: datacenter_id).update(updated_at: Time.now.utc) + end + def before_create self.created_at ||= Time.now.utc super end + + def before_save + self.updated_at = Time.now.utc + super + end end # Board state for rendering: ordered cells, hits and completed lines. @@ -145,7 +161,7 @@ module Bingo 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 + updated = [Datacenter.max(:updated_at), Incident.max(:updated_at)].compact.max new(cells, hit, wins, incidents, updated, view) end @@ -364,7 +380,10 @@ module Bingo end def self.renumber!(ids = Datacenter.order(:position, :id).select_map(:id)) - ids.each_with_index { |id, i| Datacenter.where(id: id).update(position: i + 1) } + now = Time.now.utc + ids.each_with_index do |id, i| + Datacenter.where(id: id).exclude(position: i + 1).update(position: i + 1, updated_at: now) + end end # "64" → 64.0, "25–30" → 30.0, ">40" → 40.0, "13,7" → 13.7, "" → nil @@ -388,14 +407,14 @@ module Bingo path = File.join($root, 'db', 'bingo_seed.json') return unless File.exist?(path) seed = JSON.parse(File.read(path, encoding: 'UTF-8')) - cols = Datacenter.columns.map(&:to_s) - %w[id created_at] + cols = Datacenter.columns.map(&:to_s) - %w[id created_at updated_at] DB.connection.transaction do ids = seed.fetch('datacenters').each_with_index.to_h do |row, i| dc = Datacenter.create(row.slice(*cols).merge('position' => row['position'] || i + 1)) [dc.slug, dc.id] end seed.fetch('incidents', []).each do |row| - Incident.create(row.slice(*(Incident.columns.map(&:to_s) - %w[id created_at datacenter_id])) + Incident.create(row.slice(*(Incident.columns.map(&:to_s) - %w[id created_at updated_at datacenter_id])) .merge('datacenter_id' => ids.fetch(row.fetch('slug')))) end end diff --git a/lib/bingo_admin.rb b/lib/bingo_admin.rb index ac443a8..e010bbe 100644 --- a/lib/bingo_admin.rb +++ b/lib/bingo_admin.rb @@ -221,7 +221,7 @@ module BingoAdmin app.post '/admin/bingo/incidents/:id/delete' do inc = Bingo::Incident[params[:id].to_i] - Bingo::Incident.where(id: params[:id].to_i).delete + inc&.destroy # hook bumps the datacenter's updated_at audit!('bingo.incident_delete', inc&.datacenter&.slug, inc && "#{inc.occurred_on} #{inc.title_ru}") session[:bingo_flash] = 'Incident deleted.' redirect '/admin/bingo' diff --git a/test/bingo_test.rb b/test/bingo_test.rb index 9d6ab31..22a97e8 100644 --- a/test/bingo_test.rb +++ b/test/bingo_test.rb @@ -175,6 +175,21 @@ class BingoTest < Minitest::Test assert last_response.ok? %w[ru en uk].each { |l| assert_includes last_response.body, "https://example.org/#{l}" } assert_includes last_response.body, 'hreflang="x-default"' + refute_includes last_response.body, 'changefreq' + assert_match %r{\n \d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ\n , old + DB.connection[:bingo_datacenters].update(updated_at: old) + inc = hit!(2) + DB.connection[:bingo_incidents].update(created_at: old, updated_at: old) + inc.refresh.destroy + assert_operator Bingo::Board.load.updated_on, :>, old end def test_asxpio_static_files_not_served diff --git a/views/bingo/sitemap.erb b/views/bingo/sitemap.erb index 6267d6e..fe49fef 100644 --- a/views/bingo/sitemap.erb +++ b/views/bingo/sitemap.erb @@ -3,8 +3,8 @@ <% Bingo::LOCALES.each do |l| %> <%= base_url %>/<%= l %> -<% if @lastmod %> <%= @lastmod.utc.strftime('%Y-%m-%d') %> -<% end %> daily +<% if @lastmod %> <%= @lastmod.utc.iso8601 %> +<% end %> <% Bingo::LOCALES.each do |alt| %> <% end %>