1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Unix 秒・ISO 8601・JST・PST を同時に見たい。タイムゾーン込みで全部並べる変換ツールを作った

1
Posted at

きっかけ

ログに 1721462400 と出てきて、これは秒なのかミリ秒なのか、UTC なのか JST なのかを都度頭の中で計算している時間が、通算で人生の数週間を占めていると思います。

そして new Date(1721462400).toString() で「1970 年」と出たときの脱力感、new Date(1721462400 * 1000) に書き直すのが面倒で結局ブラウザのコンソールで何度も打ち直す、あの感じ。

一度入力したら全形式を同時に表示してくれるツールがあれば、この摩擦は消えます。というわけで作りました。

作ったもの

Time Converterhttps://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.DateTimeFormattimeZone オプションに 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 件目です。

バグ報告・改善案、歓迎です。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?