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.