Cuentas Regresivas en Vivo Sin JavaScript: Turbo Streams + relative_time_in_words
La mayoría de los temporizadores de cuenta regresiva requieren bibliotecas de JavaScript. Pero con el nuevo helper relative_time_in_words de Rails 8 y Turbo Streams, puedes construir cuentas regresivas que se actualizan en vivo completamente del lado del servidor. Sin paquetes npm, sin cálculos de fechas en el cliente, y funciona correctamente cuando JavaScript está deshabilitado.
Este es un ejemplo un tanto artificial—una exploración divertida de lo que es posible con renderizado puramente del lado del servidor. En producción, probablemente combinarías cuentas regresivas en JavaScript del lado del cliente con validación del lado del servidor, lo cual usa menos recursos del servidor mientras mantiene la precisión donde importa (la aplicación real de la fecha límite). Pero veamos hasta dónde podemos llevar el enfoque del lado del servidor.
El Nuevo Helper
Rails 8 agrega relative_time_in_words, que maneja tanto tiempos pasados como futuros:
relative_time_in_words(3.hours.from_now)
# => "in about 3 hours"
relative_time_in_words(2.days.ago)
# => "2 days ago"
Esto es diferente de time_ago_in_words, que solo maneja el pasado. Tener un solo helper para ambas direcciones hace que las interfaces de cuenta regresiva sean mucho más simples.
Construyendo un Temporizador de Subasta en Vivo
Construyamos una cuenta regresiva de subasta que se actualiza cada minuto sin escribir nada de JavaScript.
El Modelo
class Auction < ApplicationRecord
def ended?
ends_at <= Time.current
end
def time_remaining
ends_at - Time.current
end
end
La Vista
<%= turbo_stream_from "auction_#{@auction.id}" %>
<div class="auction-timer">
<span id="countdown_<%= @auction.id %>"
class="<%= urgency_class(@auction.ends_at) %>">
<%= relative_time_in_words(@auction.ends_at) %>
</span>
</div>
Estilos Basados en Urgencia
Agrega un helper que retorna clases CSS basadas en el tiempo restante:
# app/helpers/countdowns_helper.rb
module CountdownsHelper
def urgency_class(deadline)
return "ended" if deadline <= Time.current
remaining = deadline - Time.current
case remaining
when 0..1.hour then "critical"
when 1.hour..1.day then "warning"
else "normal"
end
end
end
.critical { color: #dc2626; font-weight: bold; }
.warning { color: #d97706; }
.normal { color: #059669; }
.ended { color: #6b7280; }
El Job de Transmisión
Aquí es donde entra Turbo Streams. Un job recurrente transmite actualizaciones de la cuenta regresiva:
# app/jobs/countdown_broadcast_job.rb
class CountdownBroadcastJob < ApplicationJob
def perform(auction)
return if auction.ended?
Turbo::StreamsChannel.broadcast_update_to(
"auction_#{auction.id}",
target: "countdown_#{auction.id}",
html: render_countdown(auction)
)
# Programar siguiente actualización
self.class.set(wait: update_interval(auction)).perform_later(auction)
end
private
def render_countdown(auction)
ApplicationController.render(
partial: "auctions/countdown",
locals: { auction: auction }
)
end
def update_interval(auction)
remaining = auction.time_remaining
case remaining
when 0..5.minutes then 10.seconds
when 5.minutes..1.hour then 1.minute
else 5.minutes
end
end
end
El job se programa a sí mismo con intervalos dinámicos—actualizando cada 10 segundos cuando la fecha límite está cerca, pero solo cada 5 minutos cuando está lejos. Esto mantiene tu cola de jobs manejable.
El Partial
<%# app/views/auctions/_countdown.html.erb %>
<span class="<%= urgency_class(auction.ends_at) %>">
<% if auction.ended? %>
Terminada
<% else %>
<%= relative_time_in_words(auction.ends_at) %>
<% end %>
</span>
Iniciando la Cuenta Regresiva
Inicia el job de transmisión cuando se visualiza la página de la subasta:
class AuctionsController < ApplicationController
def show
@auction = Auction.find(params[:id])
CountdownBroadcastJob.perform_later(@auction) unless @auction.ended?
end
end
Otros Casos de Uso
Este patrón funciona para cualquier visualización sensible al tiempo:
Ventas Flash
<div id="sale_timer">
La venta termina <%= relative_time_in_words(@sale.ends_at) %>
</div>
SLAs de Tickets de Soporte
def sla_status(ticket)
deadline = ticket.created_at + ticket.sla_hours.hours
if deadline.future?
"Respuesta esperada #{relative_time_in_words(deadline)}"
else
"SLA incumplido #{relative_time_in_words(deadline)}"
end
end
Horarios de Eventos
<% if @event.starts_at.future? %>
Comienza <%= relative_time_in_words(@event.starts_at) %>
<% else %>
Comenzó <%= relative_time_in_words(@event.starts_at) %>
<% end %>
Cosas a Tener en Cuenta
Sobrecarga de la Cola de Jobs
Cada cuenta regresiva activa genera jobs recurrentes. Para páginas de alto tráfico, considera:
- Agrupar actualizaciones para múltiples subastas
- Usar Action Cable para actualizaciones verdaderamente en tiempo real
- Limitar el número de jobs de cuenta regresiva concurrentes
Zonas Horarias
relative_time_in_words usa Time.current, que respeta Time.zone. Asegúrate de que el manejo de zonas horarias de tu aplicación sea consistente.
Degradación Elegante
La cuenta regresiva funciona sin JavaScript—los usuarios simplemente ven un tiempo estático que era preciso cuando se cargó la página. Turbo Streams agrega las actualizaciones en vivo como una mejora.
I18n
El helper usa las traducciones integradas de Rails. Personalízalas en tus archivos de locale:
es:
datetime:
relative:
past: "hace %{time}"
future: "en %{time}"
Conclusión
Este enfoque te da cuentas regresivas en vivo con mínima complejidad. El servidor es la fuente de verdad para el tiempo, eliminando problemas de desfase de reloj del lado del cliente. Y como es solo HTML siendo transmitido, funciona en cualquier lugar donde Turbo funcione.
Para precisión verdaderamente en tiempo real (segundo a segundo), aún querrías JavaScript. Pero para la mayoría de los casos de uso—subastas, ventas, SLAs, eventos—actualizaciones a nivel de minuto son suficientes, y este enfoque del lado del servidor es más simple de construir y mantener.
Consulta el PR #55405 para la implementación de relative_time_in_words.