現行コード(main @ fc03f70)を読み直して起こした、推測なしの仕様。ストップウォッチ / カウントダウンの保存・実行。レビュー用。
最終更新: 2026-07-26 対象: Timer / TimerLap(resources :timers) 関連: Tasks 仕様書
Simple Timer は、ユーザーごとに保存できるストップウォッチ/カウントダウン。状態(経過秒・実行中・開始時刻)はサーバー権威で永続化し、表示だけをクライアントが 250ms 間隔で先読みレンダリングする。ラップ記録に対応。
mode は stopwatch か countdown の enum。with_lock)で二重加算・started_at 上書きを防ぐ(#47)。elapsed_seconds +(実行中なら)started_at として保存。実時間の差分はクライアントとサーバーが各自で足し込む。設計の核: 「動いている時間」は DB に秒数を貯めず、
started_atだけを持つ。参照時にnow - started_atを足す(current_elapsed)。pause でその差分をelapsed_secondsに確定する。これによりタブを閉じても時間が正しく進む。
timers(MariaDB / utf8mb4_uca1400_ai_ci)
| 列 | 型 | 制約・備考 |
|---|---|---|
| user_id | bigint NOT NULL | FK、index (user_id)。set_timer が user スコープで検索 → 他人の timer は 404 |
| name | varchar NOT NULL | presence + 最大 80(MAX_NAME_LENGTH) |
| mode | int NOT NULL | enum stopwatch=0 / countdown=1 |
| duration_seconds | int NULL | countdown のみ必須(> 0)。stopwatch では absence(保存時 nil 化)。CHECK NULL または >= 0 |
| elapsed_ms | int NOT NULL default 0 | 確定済み経過ミリ秒(#4)。表示は秒に丸めるが蓄積は ms で無損失。CHECK >= 0 |
| running | bool NOT NULL default false | 実行中フラグ |
| started_at | datetime NULL | 実行中の開始時刻(pause / reset で nil) |
timer_laps
| 列 | 型 | 制約・備考 |
|---|---|---|
| timer_id | bigint NOT NULL | FK、index (timer_id)。親 Timer 削除で dependent: :destroy |
| elapsed_ms | int NOT NULL | ラップ時点の current_elapsed_ms(ミリ秒)。CHECK >= 0 |
Timer / TimerLap)belongs_to :user
has_many :timer_laps, -> { order(created_at: :desc) }, dependent: :destroy
enum :mode, { stopwatch: 0, countdown: 1 }
validates :name, presence: true, length: { maximum: 80 }
validates :elapsed_ms, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :duration_seconds, numericality: { greater_than: 0 }, if: :countdown?
validates :duration_seconds, absence: true, if: :stopwatch?
時間の導出(純関数) — 保存値を書き換えずに now から算出。蓄積は ms、表示は秒(#4):
current_elapsed_ms(now) # elapsed_ms + (running? ? ((now - started_at)*1000).round : 0)
# countdown は [.., duration_ms].min でクランプ
current_elapsed(now) # current_elapsed_ms / 1000(整数秒 = floor)
display_seconds(now) # countdown → max(duration_ms - current_elapsed_ms, 0)/1000
# stopwatch → current_elapsed(秒)
状態遷移(すべて with_lock・#47):
| メソッド | 挙動 |
|---|---|
start! |
実行中なら no-op。started_at=now, running=true。ただし countdown で残り 0 のときは開始しない(unless countdown? && display_seconds.zero?) |
pause! |
非実行中なら no-op。差分を ms 精度で確定:elapsed_ms=current_elapsed_ms, started_at=nil, running=false(秒切り捨てなし・#4) |
reset! |
elapsed=0, started_at=nil, running=false + ラップ全削除 |
TimerLap: belongs_to :timer、elapsed_seconds は整数・非負。
不変条件(invariants・DB CHECK で担保):
running ⇔ started_at IS NOT NULL(chk_timers_running_started_at・#101 で双方向化)/mode=stopwatch ⇒ duration NULLかつmode=countdown ⇒ duration > 0(chk_timers_mode_duration)/elapsed_seconds >= 0/countdown はcurrent_elapsed <= duration_seconds(current_elapsedがクランプ)。 よって「running=true かつ started_at=nil」は DB で不可能(current_elapsedは元々&& started_atガード付きで nil 参照しない)。
resources :timers do
member { patch :start; patch :pause; patch :reset; post :lap }
end
| アクション | 動作 | 応答 |
|---|---|---|
| index | current_user.timers.order(created_at: :desc) を paginate |
grid 一覧 |
| new / create | build(mode: :stopwatch)。保存成功で redirect_to @timer |
失敗は :new 422 |
| edit / update | 実行中なら先に pause! してから更新(新 duration と古い started_at の齟齬を防ぐ) |
成功 303、失敗 :edit 422 |
| destroy | destroy!(laps も cascade) |
timers_path 303 |
| start / pause / reset | 対応する bang メソッド → redirect_to @timer |
303 see_other |
| lap | timer_laps.create!(elapsed_seconds: current_elapsed) |
303 see_other |
strong params:
expect(timer: [:name, :mode, :duration_seconds])。mode == "stopwatch"のときduration_secondsを nil に強制(モデルの absence 検証と二重ガード)。set_timerはcurrent_user.timers.findで他人の timer を 404。
サーバー(TimersHelper#timer_clock)とクライアント(Stimulus format)は同じ HH:MM:SS 整形を別実装で持つ。定数はサーバー Timer、クライアント config/time_units.js(TIMER_REFRESH_MILLISECONDS = 250)。
| stopwatch | countdown | |
|---|---|---|
| 表示値 | 経過(増加) | 残り = duration - 経過(0 で下限) |
| 実行中の加算 | ミリ秒で加算:サーバー ((now-started_at)*1000).round、クライアント Date.now()-Date.parse(startedAt)。表示時に floor(ms/1000)(#4) |
|
| クランプ | なし | current_elapsed を duration で上限 |
ラップは timer_clock_ms(lap.elapsed_ms)。詳細ページの時計は Stimulus が data-timer-*(elapsed は ms)から算出(サーバーの初期 HTML には数字を焼き込まず、JS が connect で描画)。
| 画面 | パス | 内容 |
|---|---|---|
| 一覧 | GET /timers | カードグリッド(名前・display_seconds・mode)+ページング。空は empty-state |
| 詳細 | GET /timers/:id | タイマーパネル(名前 / mode / 大きな時計)+アクション+ラップ(#100 でカード化) |
| 新規/編集 | GET /timers/new · :id/edit | name / mode select / duration_seconds(countdown 用・秒)。carded(#98) |
詳細ページ ワイヤーフレーム(#100 実装後)
<div class="wf__t">Focus block</div>
<div class="wf__mode">Countdown</div>
<div class="wf__clock">00:19:40</div>
<span class="wf__btn pri">▷ Start</span>
<span class="wf__btn">↺ Reset</span>
<span class="wf__btn">✎ Edit</span>
<span class="wf__btn danger">🗑 Delete</span>
<div class="wf__lap"><span>#2</span><b>00:04:20</b></div>
<div class="wf__lap"><span>#1</span><b>00:02:00</b></div>
<div class="wf__card" style="padding:.35rem .6rem"><span class="wf__btn">‹ Back to timers</span></div>
実行中は Start が Pause + Lap に切り替わる(サーバー側 running? による ERB 分岐)。
timer)static values = { mode, duration, elapsed, startedAt, running }
connect() → render(); running なら setInterval(render, 250ms)
render() → elapsed + (running && startedAt ? floor((Date.now()-Date.parse(startedAt))/1000) : 0)
countdown ? max(duration - elapsed, 0) : elapsed → format(HH:MM:SS)
disconnect() → clearInterval
サーバーからの data-timer-*(mode / duration / elapsed / started-at / running)だけで動くため、Turbo 遷移後も再接続で正しく再開する。停止中はインターバルを張らない(無駄な再描画なし)。
シリアライズ形式(重要・#101/#1):
data-timer-started-atはstarted_at.utc.iso8601(…Z)で出力する。Rails のデフォルトto_s(2026-07-26 12:00:00 UTC)は Safari のDate.parseでNaNになり時計が壊れる。
端末時計ズレ(#101/Q4): クライアントは
Date.now()を素で信頼するため、端末時計が数分ずれた環境では countdown が嘘の値を出す。将来的にはdata-timer-server-nowを渡しoffset = Date.now() - serverNowを差し引くと解消(未実装・追跡中)。
Timer state machine
リクエスト → 再描画フロー
timers.*(title / subtitle / start / pause / reset / lap / laps / no_laps / back / created / updated / destroyed / confirm_delete / confirm_reset / duration_hint / duration_placeholder / modes.{stopwatch,countdown})。en/ja/de/zh。data-turbo-confirm(reset=ラップ消去警告、delete)。tabular-nums で桁ブレ無し。2026-07-26 のコードレビュー指摘と対応状況。
✅ 対応**#1 started_at のシリアライズ.** started_at.utc.iso8601 に変更(Safari の Date.parse 対策)。§7 参照。
✅ 対応**#2 running ⇔ started_at.** CHECK を双方向に強化(chk_timers_running_started_at)。従来の片方向 CHECK でも nil クラッシュは防げていたが、片側(running=0 で started_at 残存)を許していた点を封鎖。
✅ 対応**#3 CHECK ↔ 検証の不一致.** chk_timers_mode_duration:stopwatch ⇒ NULL / countdown ⇒ > 0 を DB で担保(モデルと一致)。
✅ 対応**#4 floor 累積(systematic).** elapsed_seconds → 整数ミリ秒 elapsed_ms(timers / timer_laps、×1000 移行)。pause! は ms で確定、表示のみ秒に丸め(current_elapsed/display_seconds/timer_clock_ms、JS も ms 蓄積→floor)。started_at は既に datetime(6)。テストで 5×600ms=3000ms が 0 に落ちないことを担保。
✅ 対応**#5 lap 連打・無制限.** MAX_LAPS=100 をサーバーで拒否(timers.lap_limit)。送信中のボタン無効化は Turbo の既定挙動。
✅ 対応**#6 tiebreaker.** index・laps とも order(created_at: :desc, id: :desc)。
Q1 countdown 完了 = 導出状態. イベントではなく finished? = countdown? && display_seconds.zero? として持つ。current_elapsed は duration でクランプ済みなので DB は放置で正。必要なのは (a) UI バッジ、(b) クライアント 0 到達時の通知/音、(c) 次回書込み系での遅延確定 の 3 点のみ。GET での自動 pause! は副作用なので禁止。
Q2 countdown ラップ. 保存は current_elapsed のまま(唯一の真実)。表示側だけ duration - lap.elapsed_seconds にすれば一貫。ただし後から duration を編集すると過去ラップの残り表示が動く → 気にするなら lap に duration_snapshot。
Q3 Free 上限. timer 本体は無制限で実害小。効くのは laps 側(#5 で対処済)。
Q4 端末時計ズレ. 丸め差より 端末時計そのもののズレが効く。data-timer-server-now + offset で解消(未実装・§7 に記載)。
Q5 編集で自動 pause. 挙動は正。フォームに「保存すると計測は一時停止します」hint を出すと親切。pause!+update の隙間 start! は極小リスク(将来 with_lock で包む余地)。
✅ 対応Q6 絵文字. timers/index の見出しを icon(:stopwatch) に。
✅ 対応Q7 並び順. 固定で可。tiebreaker のみ追加(#6)。
duration_seconds は nil 化(params + chk_timers_mode_duration)。elapsed_seconds は引き継ぐ。ラップは保持(reset しない限り)。編集保存時に実行中なら pause! 済。elapsed > 新 duration の場合、display_seconds は即 0(= finished 扱い)。start! は残り 0 のため開始しない。現状持たない:完了通知・音・バックグラウンドジョブ(実行はクライアント tick のみ)・共有・エクスポート・複数同時実行の集約表示。Q1 はこの線引きの範囲。
250ms 更新の時計を aria-live にすると読み上げが暴走する。時計は role="timer" かつ live region にせず、完了時だけ別の aria-live="polite" な status 要素で一度アナウンスするのが定石(未実装・要対応)。
duration を秒だけで入力させるのは辛い(25 分 = 1500)。mm:ss 入力か 分/秒 2 フィールドを検討。
Toddyi — Simple Timer 仕様書(レビュー用・public/timers-spec.html)。コード main @ fc03f70 準拠。