現行コード(main @ 48dbb9e)を読み直して起こした、推測なしの仕様。残り寿命を砂時計で可視化する memento-mori 機能。レビュー用。
最終更新: 2026-07-26 対象: User の life 設定(resource :life) 関連: Simple Timer 仕様書 · Tasks 仕様書
Life Hourglass は、生年月日と平均寿命から予想される死亡日を導出し、そこまでの残り時間を砂時計 SVGで可視化する。上のバルブ=残り、下のバルブ=経過。数値は「残り日数」「残り起床秒(睡眠を除く)」「残り総秒」。
users の列(birth_on / life_expectancy_years / daily_sleep_hours)に設定を保存。birth_on + life_expectancy_years.years)で列を持たない。requestAnimationFrame でリアルタイム更新。設計の核: Simple Timer と同じく「時間を貯めない」。持つのは
birth_onとlife_expectancy_yearsだけで、経過・残りは常にnowから算出する。
users 列)| 列 | 型 | 制約・備考 |
|---|---|---|
| birth_on | date NULL | 生年月日。モデル検証 <= Date.current(未来日は 422・#102) |
| life_expectancy_years | int NULL | 平均寿命(年)。CHECK NULL または > 0 + モデル検証 1..150(#102) |
| daily_sleep_hours | decimal(3,1) default 8.0 | 1 日の睡眠時間。CHECK NULL または 0〜24(chk_users_sleep_hours) |
導出値(列なし):death_on、awake_fraction、経過/残り時間。
User)def death_on # 未設定なら nil。birth_on + life_expectancy_years.years
# rescue Date::Error, RangeError => nil(唯一の判定元)
def life_configured? # death_on.present?(双方向の不変条件・#102)
def awake_fraction # ((24 - daily_sleep_hours.to_f) / 24).clamp(0, 1)
# daily_sleep_hours は NOT NULL default 8.0(#102)
不変条件:
death_onはlife_configured?のときだけ非 nil。awake_fraction ∈ [0,1]。設定 3 列以外に life 状態は持たない(唯一の真実は now と 2 つの日付)。
resource :life, only: [ :show, :update ], controller: "life"
| アクション | 動作 |
|---|---|
| show | @user = current_user を描画(設定済みなら砂時計、未設定なら configure_prompt) |
| update | @user.update(life_params) → 成功で redirect_to life_path(303)、失敗は :show 422 |
strong params:
expect(user: [:birth_on, :life_expectancy_years, :daily_sleep_hours])。設定は自分の Userのみ(current_user)。
422 になる条件(#102 で検証追加):
life_expectancy_years ∉ 1..150/daily_sleep_hours ∉ 0..24/birth_on > Date.current(未来日)。以前はモデル検証が無く DB CHECK 直行で **500(StatementInvalid)**だった(フォーム迂回 PATCH の攻撃経路)。
SVG は viewBox 0 0 100 170。上バルブの砂領域 y 12→82、下バルブ y 88→158、各 span=70。経過率 elapsed = clamp((now - birth) / (death - birth), 0, 1)。
| 上の砂(残り) | 下の砂(経過) | |
|---|---|---|
| y | 12 + elapsed·70 |
158 − elapsed·70 |
| height | (1 − elapsed)·70 |
elapsed·70 |
**サーバー(SSR・JS 無しでも成立)とクライアント(rAF)**が同じ式を持つ。数値表示:
remainingMs = max(0, death - now)
days = floor(remainingMs / 86,400,000)
totalSeconds = remainingMs / 1000
awakeSeconds = totalSeconds × awakeFraction # awakeFraction = (24 - sleep)/24
stream 不透明度 = remainingMs <= 0 ? 0 : 1 # 死亡日到達で砂の落下を止める
total <= 0(birth ≥ death)のときは elapsed = 1(0 除算回避)。表示 3 値はすべて Math.floor 済みの整数(小数は出ない)。
タイムゾーン・境界(要定義・#102):
birth_on/death_onは Date。data-*-atはto_time.to_i*1000=サーバー TZ のその日 00:00のミリ秒。days = floor(ms / 86,400,000)はカレンダー日ではなく 24 時間ブロックで、繰り下がりは深夜 0 時ではなく誕生時刻に依存。SSR はサーバー時計、rAF はデバイス時計のため、時計がずれた端末は初回描画で砂が跳ねる(将来server-nowオフセットで解消可)。
座標定数の重複:
12 / 70 / 158が Ruby(SSR)と JS(TOP_Y/SAND_SPAN/BOTTOM_Y)に散在。将来transform: scaleY(--elapsed)に寄せれば単一情報源化できる(デザイン刷新 #103 と合わせて検討)。
| 状態 | 表示 |
|---|---|
未設定(!life_configured?) |
configure_prompt(empty-state)+設定フォーム |
| 設定済み | 砂時計 SVG(銅板に名前を刻印)+ 3 スタッツ + note +設定フォーム |
刻印 _engraved は name 先頭 18 文字のみ(無ければ無刻印・#102/Q4 でメール露出を廃止)。textLength+lengthAdjust で ja/zh 幅溢れ回避。SVG は role="img" + aria-label。到達時は aria-live="polite" の status 要素で一度だけ告知(#102/Q5)。
ワイヤーフレーム
<svg viewBox="0 0 100 170" aria-hidden="true">
<path d="M12 12 H88 L54 82 H46 Z M46 88 H54 L88 158 H12 Z" fill="none" stroke="#a6702f" stroke-width="3"/>
<rect x="5" y="47" width="90" height="35" fill="#ecc472" opacity="0.9" clip-path="url(#c)"/>
<rect x="5" y="123" width="90" height="35" fill="#cf9235" opacity="0.95"/>
<clipPath id="c"><path d="M12 12 H88 L54 82 H46 Z"/></clipPath>
<line x1="50" y1="82" x2="50" y2="156" stroke="#cf9235" stroke-width="1.5"/>
</svg>
<div class="wf__stat"><b>18,262</b><span>Days left</span></div>
<div class="wf__stat"><b>1,051,891,200</b><span>Awake seconds left</span></div>
<div class="wf__stat"><b>1,577,836,800</b><span>Total seconds left</span></div>
<div class="wf__note">Awake time excludes 8h of sleep per day. Estimated final day: …</div>
<span class="wf__f">Date of birth</span>
<span class="wf__f">Life expectancy (years)</span>
<span class="wf__f">Sleep (hours/day)</span>
<span class="wf__f" style="border-color:var(--accent);color:var(--accent)">Save</span>
life-hourglass)static values = { deathAt, birthAt, sleepHours } // deathAt/birthAt はミリ秒
connect() → awakeFraction 算出、requestAnimationFrame(tick)
tick() → now=Date.now(); elapsed=clamp((now-birth)/(death-birth),0,1)
sandTop/Bottom の y・height を更新、stream 不透明度、days/seconds/awakeSeconds を更新
!expired の間だけ次フレームを予約(死亡日で停止)
disconnect()→ cancelAnimationFrame
データ属性(death-at / birth-at ms、sleep-hours)だけで動く。requestAnimationFrame はタブ非表示で自動停止(省電力)。fmt は toLocaleString で桁区切り。
life.*(title / subtitle / settings_title / configure_prompt / save / fields.* / units.{days,awake_seconds,total_seconds} / note)。en/ja/de/zh。role="img" + aria-label。数値は毎フレーム更新されるが aria-live は無し(読み上げ暴走を避ける・Simple Timer と同じ論点)。2026-07-26 のコードレビュー指摘と対応状況。
✅ 対応**#1 検証欠如=500.** フォーム迂回 PATCH(sleep=25 等)が DB CHECK 直行で StatementInvalid 500 を投げていた。モデル検証(expectancy 1..150/sleep 0..24/birth <= today)を追加し 422 に。§4 参照。
✅ 対応**#2 sleep の nil 齟齬.** DB default 8.0 だが awake_fraction の fallback が ||0。列を NOT NULL DEFAULT 8.0 にして分岐を撤去。
✅ 対応**#3/#4 death_on・不変条件.** RangeError も rescue。life_configured? = death_on.present? に寄せて双方向の不変条件に(循環無し)。閏日テスト追加。
✅ 対応**#5(小数表示).** JS は元々 Math.floor 済み(小数は出ない)。仕様書に明記(§5)。
✅ 対応Q2 awake の意味. 一律控除で妥当(正確な予測ではなく memento mori)。note を「概算」+ユーザー設定の睡眠時間補間に。
✅ 対応Q3 rAF. rAF は残す(タブ非表示で自動停止)。前フレームと同値なら DOM を書かないガードを追加。prefers-reduced-motion で砂の落下/粒を停止。座標の単一情報源化(scaleY)は #103 で検討。
✅ 対応Q4 刻印. name のみ・無ければ無刻印(メール露出廃止)。
✅ 対応Q5 到達演出. expired は状態固定 → 唯一安全に aria-live="polite" を使える場所。中立文言で一度だけ告知。
✅ 対応Q6 絵文字. 見出しを icon(:hourglass) に。
Q7 単位. death_on = birth + expectancy なので「想定寿命(歳)」で既に直感的(ja/zh は既に「歳/岁」ラベル)。「余命」入力は now 依存で「時間を貯めない」原則と衝突するため入れない。
✅ 実装済デザイン刷新(#103 / #104-#106): 砂を温かい琥珀グラデ、ガラス枠を
--pico-primaryで両テーマ視認+曲線バルブ、真鍮キャップ、刻印は濃茶(青字廃止)。挙動・座標は不変(skin のみ)。
Toddyi — Life Hourglass 仕様書(レビュー用・public/life-spec.html)。コード main @ 48dbb9e 準拠。