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_wordsはTime.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を参照してください。