JavaScriptなしでライブカウントダウン:Turbo Streams + relative_time_in_words

ほとんどのカウントダウンタイマーにはJavaScriptライブラリが必要です。しかし、Rails 8の新しいrelative_time_in_wordsヘルパーとTurbo Streamsを使えば、完全にサーバーサイドでライブ更新されるカウントダウンを構築できます。npmパッケージ不要、クライアントサイドの日付計算不要、そしてJavaScriptが無効でも正常に動作します。

これは少し人工的な例です—純粋なサーバーサイドレンダリングで何ができるかを楽しく探求したものです。本番環境では、クライアントサイドのJavaScriptカウントダウンとサーバーサイドのバリデーションを組み合わせるでしょう。これはサーバーリソースの使用を抑えつつ、重要な部分(実際の締め切りの適用)では精度を維持します。しかし、サーバーサイドアプローチをどこまで推し進められるか見てみましょう。

新しいヘルパー

Rails 8はrelative_time_in_wordsを追加しました。これは過去と未来の両方の時間を処理します:

relative_time_in_words(3.hours.from_now)
# => "in about 3 hours"

relative_time_in_words(2.days.ago)
# => "2 days ago"

これは過去のみを処理するtime_ago_in_wordsとは異なります。両方向に対応する単一のヘルパーがあることで、カウントダウンUIがはるかにシンプルになります。

ライブオークションタイマーの構築

JavaScriptを一切書かずに、毎分更新されるオークションカウントダウンを構築しましょう。

モデル

class Auction < ApplicationRecord
  def ended?
    ends_at <= Time.current
  end

  def time_remaining
    ends_at - Time.current
  end
end

ビュー

<%= 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>

緊急度ベースのスタイリング

残り時間に基づいてCSSクラスを返すヘルパーを追加します:

# 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; }

ブロードキャストジョブ

ここでTurbo Streamsの出番です。繰り返しジョブがカウントダウンの更新をブロードキャストします:

# 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)
    )

    # 次の更新をスケジュール
    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

ジョブは動的な間隔で自身をスケジュールします—締め切りが近いときは10秒ごとに更新し、遠いときは5分ごとにのみ更新します。これによりジョブキューを管理可能に保ちます。

パーシャル

<%# app/views/auctions/_countdown.html.erb %>
<span class="<%= urgency_class(auction.ends_at) %>">
  <% if auction.ended? %>
    終了
  <% else %>
    <%= relative_time_in_words(auction.ends_at) %>
  <% end %>
</span>

カウントダウンの開始

オークションページが表示されたときにブロードキャストジョブを開始します:

class AuctionsController < ApplicationController
  def show
    @auction = Auction.find(params[:id])
    CountdownBroadcastJob.perform_later(@auction) unless @auction.ended?
  end
end

その他のユースケース

このパターンは時間に敏感な表示に使えます:

フラッシュセール

<div id="sale_timer">
  セール終了 <%= relative_time_in_words(@sale.ends_at) %>
</div>

サポートチケットのSLA

def sla_status(ticket)
  deadline = ticket.created_at + ticket.sla_hours.hours
  if deadline.future?
    "対応期限 #{relative_time_in_words(deadline)}"
  else
    "SLA違反 #{relative_time_in_words(deadline)}"
  end
end

イベントスケジュール

<% if @event.starts_at.future? %>
  開始 <%= relative_time_in_words(@event.starts_at) %>
<% else %>
  開始済み <%= relative_time_in_words(@event.starts_at) %>
<% end %>

注意点

ジョブキューのオーバーヘッド

アクティブなカウントダウンごとに繰り返しジョブが生成されます。高トラフィックページでは以下を検討してください:

  • 複数のオークションの更新をバッチ処理
  • 真のリアルタイム更新にはAction Cableを使用
  • 同時カウントダウンジョブの数を制限

タイムゾーン

relative_time_in_wordsTime.zoneを尊重するTime.currentを使用します。アプリケーションのタイムゾーン処理が一貫していることを確認してください。

グレースフルデグラデーション

カウントダウンはJavaScriptなしで動作します—ユーザーはページ読み込み時に正確だった静的な時間を見るだけです。Turbo Streamsはライブ更新を拡張機能として追加します。

I18n

ヘルパーはRails組み込みの翻訳を使用します。ロケールファイルでカスタマイズしてください:

ja:
  datetime:
    relative:
      past: "%{time}前"
      future: "%{time}後"

まとめ

このアプローチは最小限の複雑さでライブカウントダウンを実現します。サーバーが時間の信頼できる情報源となり、クライアントサイドの時計のずれの問題を排除します。そして、ストリーミングされるのは単なるHTMLなので、Turboが動作するところならどこでも動作します。

真のリアルタイム精度(秒単位)には、やはりJavaScriptが必要です。しかし、ほとんどのユースケース—オークション、セール、SLA、イベント—では分単位の更新で十分であり、このサーバーサイドアプローチは構築と保守がより簡単です。

relative_time_in_wordsの実装についてはPR #55405を参照してください。