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
This commit is contained in:
Sergei Poljanski 2026-10-10 09:30:37 +04:00
commit 6f37e537ca
Signed by: asxpi
GPG key ID: 4F8851660FA4121B
5 changed files with 54 additions and 7 deletions

View file

@ -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

View file

@ -99,6 +99,11 @@ module Bingo
super super
end end
def before_save
self.updated_at = Time.now.utc
super
end
def operational? = status == 'operational' def operational? = status == 'operational'
def planned? = status == 'planned' def planned? = status == 'planned'
@ -126,10 +131,21 @@ module Bingo
include Localized include Localized
many_to_one :datacenter, class: 'Bingo::Datacenter' 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 def before_create
self.created_at ||= Time.now.utc self.created_at ||= Time.now.utc
super super
end end
def before_save
self.updated_at = Time.now.utc
super
end
end end
# Board state for rendering: ordered cells, hits and completed lines. # Board state for rendering: ordered cells, hits and completed lines.
@ -145,7 +161,7 @@ module Bingo
hit = cells.map { |dc| dc.incidents.any? } hit = cells.map { |dc| dc.incidents.any? }
wins = LINES.select { |line| line.all? { |i| hit[i] } } wins = LINES.select { |line| line.all? { |i| hit[i] } }
incidents = cells.flat_map(&:incidents).sort_by { |i| [i.occurred_on, i.id] }.reverse 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) new(cells, hit, wins, incidents, updated, view)
end end
@ -364,7 +380,10 @@ module Bingo
end end
def self.renumber!(ids = Datacenter.order(:position, :id).select_map(:id)) 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 end
# "64" → 64.0, "25–30" → 30.0, ">40" → 40.0, "13,7" → 13.7, "" → nil # "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') path = File.join($root, 'db', 'bingo_seed.json')
return unless File.exist?(path) return unless File.exist?(path)
seed = JSON.parse(File.read(path, encoding: 'UTF-8')) 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 DB.connection.transaction do
ids = seed.fetch('datacenters').each_with_index.to_h do |row, i| 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 = Datacenter.create(row.slice(*cols).merge('position' => row['position'] || i + 1))
[dc.slug, dc.id] [dc.slug, dc.id]
end end
seed.fetch('incidents', []).each do |row| 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')))) .merge('datacenter_id' => ids.fetch(row.fetch('slug'))))
end end
end end

View file

@ -221,7 +221,7 @@ module BingoAdmin
app.post '/admin/bingo/incidents/:id/delete' do app.post '/admin/bingo/incidents/:id/delete' do
inc = Bingo::Incident[params[:id].to_i] 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}") audit!('bingo.incident_delete', inc&.datacenter&.slug, inc && "#{inc.occurred_on} #{inc.title_ru}")
session[:bingo_flash] = 'Incident deleted.' session[:bingo_flash] = 'Incident deleted.'
redirect '/admin/bingo' redirect '/admin/bingo'

View file

@ -175,6 +175,21 @@ class BingoTest < Minitest::Test
assert last_response.ok? assert last_response.ok?
%w[ru en uk].each { |l| assert_includes last_response.body, "<loc>https://example.org/#{l}</loc>" } %w[ru en uk].each { |l| assert_includes last_response.body, "<loc>https://example.org/#{l}</loc>" }
assert_includes last_response.body, 'hreflang="x-default"' assert_includes last_response.body, 'hreflang="x-default"'
refute_includes last_response.body, 'changefreq'
assert_match %r{</loc>\n <lastmod>\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ</lastmod>\n <xhtml:link}, last_response.body
end
def test_updated_on_follows_edits_and_deletes
old = Time.utc(2026, 1, 1)
DB.connection[:bingo_datacenters].update(created_at: old, updated_at: old)
assert_equal old, Bingo::Board.load.updated_on
@dcs[5].refresh.update(name_en: 'Renamed')
assert_operator Bingo::Board.load.updated_on, :>, 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 end
def test_asxpio_static_files_not_served def test_asxpio_static_files_not_served

View file

@ -3,8 +3,8 @@
<% Bingo::LOCALES.each do |l| %> <% Bingo::LOCALES.each do |l| %>
<url> <url>
<loc><%= base_url %>/<%= l %></loc> <loc><%= base_url %>/<%= l %></loc>
<% if @lastmod %> <lastmod><%= @lastmod.utc.strftime('%Y-%m-%d') %></lastmod> <% if @lastmod %> <lastmod><%= @lastmod.utc.iso8601 %></lastmod>
<% end %> <changefreq>daily</changefreq> <% end %>
<% Bingo::LOCALES.each do |alt| %> <xhtml:link rel="alternate" hreflang="<%= alt %>" href="<%= base_url %>/<%= alt %>"/> <% Bingo::LOCALES.each do |alt| %> <xhtml:link rel="alternate" hreflang="<%= alt %>" href="<%= base_url %>/<%= alt %>"/>
<% end %> <xhtml:link rel="alternate" hreflang="x-default" href="<%= base_url %>/"/> <% end %> <xhtml:link rel="alternate" hreflang="x-default" href="<%= base_url %>/"/>
</url> </url>