きっかけ
ログに 1721462400 と出てきて、これは秒なのかミリ秒なのか、UTC なのか JST なのかを都度頭の中で計算している時間が、通算で人生の数週間を占めていると思います。
そして new Date(1721462400).toString() で「1970 年」と出たときの脱力感、new Date(1721462400 * 1000) に書き直すのが面倒で結局ブラウザのコンソールで何度も打ち直す、あの感じ。
一度入力したら全形式を同時に表示してくれるツールがあれば、この摩擦は消えます。というわけで作りました。
作ったもの
Time Converter — https://sen.ltd/portfolio/time-converter/
1 つの入力欄に何か貼り付けると、下に 9 行同時表示:
- Unix 秒
- Unix ミリ秒
- ISO 8601 (UTC)
- RFC 2822 (GMT)
- JST (Asia/Tokyo, DST 込み)
- PST (America/Los_Angeles, DST 込み)
- EST (America/New_York, DST 込み)
- CET (Europe/Berlin, DST 込み)
- 相対時刻(「5 分前」「3 日後」)
vanilla JS + HTML + CSS、ゼロ依存、ビルドツール不要。タイムゾーンは Intl.DateTimeFormat に任せて、DST やうるう秒の面倒はブラウザが見てくれる。300 行くらい、node --test で 14 ケース。
入力の自動形式判別
時刻を表す文字列のフォーマットは、現場で見かけるだけでも 5 種類以上あります。入力欄 1 つで受け止めるには、自動判別が必須。
export function parseTime(input) {
const s = input.trim()
// Unix ミリ秒 (13桁)
if (/^-?\d{13}$/.test(s)) return { ok: true, ms: Number(s), format: 'unix-ms' }
// Unix 秒 (10桁)
if (/^-?\d{10}$/.test(s)) return { ok: true, ms: Number(s) * 1000, format: 'unix-s' }
// Unix マイクロ秒 (16桁) — ログでたまに見る
if (/^-?\d{16}$/.test(s)) return { ok: true, ms: Math.round(Number(s) / 1000), format: 'unix-us' }
// ISO 8601
const isoMatch = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?(Z|[+-]\d{2}:?\d{2})?$/.exec(s)
// ...
}
桁数で秒 / ミリ秒 / マイクロ秒を判別しているのがポイント。2001 年 9 月 9 日以降(Unix 秒が 10 桁になった)、いまの時代は 10 桁 = 秒、13 桁 = ミリ秒という区別で正確です。16 桁のマイクロ秒対応は、分散トレーシングで TraceEvent.timestamp がマイクロ秒のシステムが多いので足しておきました。
ISO 8601 で TZ がないときの扱い
2024-07-20T08:00:00 みたいに TZ 指定がない ISO 文字列をどう解釈するかは、実は仕様 (RFC 3339) では「ローカル時刻」扱いなんだけど、ログに出てくる TZ 無し ISO は 99% UTC のつもりで書かれている、というのが経験則。
new Date('2024-07-20T08:00:00') をブラウザで実行するとローカル TZ で解釈されます(日本からなら UTC+9 扱い)。これはたいてい嫌な挙動なので、明示的に Date.UTC() で UTC として解釈しなおします:
if (!tz) {
const t = Date.UTC(
Number(Y), Number(M) - 1, Number(D),
Number(h), Number(m), Number(sec), Number(msPadded)
)
return { ok: true, ms: t, format: 'iso (UTC assumed)' }
}
フォーマット表示に iso (UTC assumed) と付けて、「TZ なしだったから UTC と仮定しました」と明示しています。嘘をつかない UI が大事。
Intl.DateTimeFormat で DST を全部丸投げ
JST は UTC+9 固定なので簡単ですが、PST / EST / CET は夏時間があるので毎月 offset が変わります。これを自分で計算すると地獄です。
幸い、Intl.DateTimeFormat の timeZone オプションに IANA 名を渡せば、ブラウザが裏で全部やってくれます:
export function formatInTz(ms, tz) {
const fmt = new Intl.DateTimeFormat('en-GB', {
timeZone: tz, // 'America/Los_Angeles' 等
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
timeZoneName: 'short',
})
const parts = fmt.formatToParts(new Date(ms))
const get = (t) => parts.find((p) => p.type === t)?.value ?? ''
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}:${get('second')} ${get('timeZoneName')}`
}
formatToParts を使っているのは、ブラウザが返す順序(DD/MM/YYYY なのか YYYY-MM-DD なのか)に依存せずに自分で並べたいから。en-GB ロケールを選んだのは h23(00-23 時間表記)との相性がよくて DD/MM/YYYY を返してくれるから、という完全にハック的理由。
嬉しいのは 夏時間の遷移が自動で反映されること。timeZoneName: 'short' を指定しておくと、ある瞬間は PST と返して、3 月の DST 開始後は PDT と返してくれます。
formatInTz(Date.parse('2024-02-01T00:00:00Z'), 'America/Los_Angeles')
// '2024-02-01 00:00:00 PST' (日本時間 16:00)
formatInTz(Date.parse('2024-07-01T00:00:00Z'), 'America/Los_Angeles')
// '2024-07-01 00:00:00 PDT' (夏時間なので D)
DST の切り替わり瞬間のチェックも、ロジックなしで正確に。
相対時刻は粒度を decreasing に並べて最初にマッチしたもの勝ち
「5 分前」とか「3 日後」みたいな表現は、単位のリストを大きい順に並べて、最初に閾値を超えた単位を採用する方式:
export function humanRelative(ms, nowMs = Date.now()) {
const diff = Math.floor((ms - nowMs) / 1000)
const absS = Math.abs(diff)
const units = [
{ unit: 'year', seconds: 365 * 24 * 3600 },
{ unit: 'month', seconds: 30 * 24 * 3600 },
{ unit: 'week', seconds: 7 * 24 * 3600 },
{ unit: 'day', seconds: 24 * 3600 },
{ unit: 'hour', seconds: 3600 },
{ unit: 'minute', seconds: 60 },
{ unit: 'second', seconds: 1 },
]
for (const { unit, seconds } of units) {
if (absS >= seconds) {
const n = Math.floor(absS / seconds)
const suffix = diff >= 0 ? 'from now' : 'ago'
return `${n} ${unit}${n === 1 ? '' : 's'} ${suffix}`
}
}
return 'just now'
}
Intl に Intl.RelativeTimeFormat という専用 API もあるんですが、本ツールでは粒度の閾値を自分で選びたい(1 秒未満を "just now" にまとめる、等)ので自前実装にしています。
「1 month」は実際には「30 日」を意味していて正確じゃないんですが、相対表示として読む分には直感と合うので問題なし。
テスト
node --test で 14 ケース。主要なもの:
- Unix 秒 / ミリ秒 / マイクロ秒の桁数判別
- ISO 8601 with / without TZ
- RFC 2822
- 日本語スペースが入った謎フォーマット(→ native fallback に流れる)
- DST 境界日の formatInTz
- 相対時刻(過去 / 未来 / 今)
npm test
Intl.DateTimeFormat のテストは Node.js 自体の Intl サポートを使うので、事前に full-icu が入ったビルドが必要(Node 18+ なら標準で OK)。
姉妹ツール
Cron TZ Viewer と同じ哲学で作っていて、どちらも「時刻関連の問題に Intl と ゼロ依存で挑む」シリーズです。こっちは「固定の時刻」、あっちは「繰り返しパターン」を扱います。
おわりに
SEN 合同会社の ポートフォリオシリーズ 100+ の 7 件目です。
- 📦 レポジトリ: https://github.com/sen-ltd/time-converter
- 🌐 ライブデモ: https://sen.ltd/portfolio/time-converter/
- 🏢 会社: https://sen.ltd/
バグ報告・改善案、歓迎です。
