Temporal を使用したイベントの差分の計算と期間のフォーマット

ウェブ機能: Temporal Intl.DurationFormat

イベント(トライアルの有効期限、サブスクリプションのカウントダウン、SLA ウィンドウなど)間の経過時間や残り時間を計算し、それらの期間をローカライズされた人が読める形式で表示するには、これまで手動での計算や外部ライブラリが必要でした。以前の Date オブジェクトはエポック ミリ秒のみを追跡するため、暦月の計算、夏時間(DST)の移行、単位のバランス調整でエラーが発生しやすくなります。

Temporal API は、Temporal.ZonedDateTime を Temporal.Duration および Intl.DurationFormat と組み合わせて、DST セーフな時間差、構成可能な単位バランス、カレンダー対応の期間比較、ローカライズされた形式を提供します。

実装方法

  1. Temporal.ZonedDateTime エンドポイントを取得する: システム タイムゾーンの現在の時刻には Temporal.Now.zonedDateTimeISO() を使用します。ターゲット イベントのタイムゾーンがユーザーのシステムのデフォルトと異なる場合は、明示的な IANA タイムゾーン文字列 Temporal.Now.zonedDateTimeISO(timeZone) を渡します。
  2. .since() と .until() を使用して差分を計算する:
    • now.since(start, options) を使用して、過去のイベントからの経過時間を計算します。
    • now.until(end, options) を使用して、将来のイベントまでの残り時間を計算します。
    • largestUnit('year'、'month'、'day'、'hour' など)と smallestUnit('minute' など)を渡して、結果の Temporal.Duration のバランスを調整して丸めます。
  3. スタンドアロンの Temporal.Duration オブジェクトのバランスを調整または比較する:
    • Temporal.Duration.from({ minutes: 90 }) でスタンドアロンの期間を作成し、duration.round({ largestUnit: 'hour' }) で単位を再調整します(暦月、暦年、夏時間の移行をまたいで調整する場合は relativeTo を指定します)。
    • Temporal.Duration.compare(d1, d2, { relativeTo }) を使用して 2 つの期間を比較します。いずれかの期間にカレンダー単位(years、months、weeks)が含まれている場合、または夏時間の境界をまたぐ場合は、PlainDate または ZonedDateTime を relativeTo として渡す必要があります。
  4. 人間向けとマシン向けの形式:
    • ユーザー向けのローカライズされたテキストには、duration.toLocaleString(locale, options)(Intl.DurationFormat に委任)を使用します。
    • duration.toString() は、機械で読み取り可能な ISO 8601 期間文字列(P1M2DT4H など)にのみ使用します。

例: 試用期間の終了までの日数とローカライズされた形式

// 1. Get the current time in an explicit target IANA time zone (or omit the
// argument to use the system's current time zone).
const tz = 'America/New_York';
const now = Temporal.Now.zonedDateTimeISO(tz);

// 2. Combine separate date and time strings directly.
// Do not build an ISO string with template literals; chain Temporal methods instead.
const startDateStr = '2025-01-01';
const startTimeStr = '12:00:00';
const endDateStr = '2025-01-31';
const endTimeStr = '12:00:00';

const start = Temporal.PlainDate.from(startDateStr).toPlainDateTime(startTimeStr).toZonedDateTime(tz);
const end = Temporal.PlainDate.from(endDateStr).toPlainDateTime(endTimeStr).toZonedDateTime(tz);

// 3. Calculate differences using .since() and .until()
// ZonedDateTime differences default to largestUnit: 'hour' (days are not a fixed
// length across DST, so Temporal won't balance into days unless asked). Pass
// largestUnit: 'day' or a calendar unit ('week', 'month', 'year') when you want a
// human-scale breakdown instead of e.g. PT720H. smallestUnit rounds away
// seconds and sub-second noise so the formatted string stays readable.
const timeActive = now.since(start, { largestUnit: 'year', smallestUnit: 'minute' });
const timeRemaining = now.until(end, { largestUnit: 'year', smallestUnit: 'minute' });

// 4. Format durations for display with .toLocaleString() (Intl.DurationFormat)
// instead of manually concatenating unit numbers and suffixes. 'en-US' is used
// here for deterministic output; pass undefined to use the user's locale.
console.log(`Active: ${timeActive.toLocaleString('en-US', { style: 'narrow' })}`);
console.log(`Remaining: ${timeRemaining.toLocaleString('en-US', { style: 'long' })}`);

// Use .toString() for ISO 8601 serialization (e.g., API payloads or <time datetime="...">)
console.log(`ISO 8601: ${timeRemaining.toString()}`);

// 5. Check status via .sign, and compare durations with Temporal.Duration.compare()
const isExpired = timeRemaining.sign < 0;
// Pass relativeTo when comparing durations that may include calendar months/years or DST shifts
const isPastHalfway = Temporal.Duration.compare(timeActive, timeRemaining, { relativeTo: now }) > 0;

例: スタンドアロンの期間(round と Intl.DurationFormat)のバランス調整

// Convert a raw unit count (e.g., 90 minutes) into a balanced breakdown (1 hour, 30 minutes)
const rawDuration = Temporal.Duration.from({ minutes: 90 });
const balanced = rawDuration.round({ largestUnit: 'hour' });

// Format via duration.toLocaleString() or directly with Intl.DurationFormat
const formatter = new Intl.DurationFormat('en-US', { style: 'long' });
console.log(formatter.format(balanced)); // "1 hour, 30 minutes"

戦略的な実装とベスト プラクティス

  • 推奨: 特定のタイムゾーンで発生する現実世界のイベント(サブスクリプションの更新やイベントのカウントダウンなど)に関連する計算には Temporal.ZonedDateTime を使用し、システムのデフォルト以外のゾーンに対して計算する場合は、明示的な timeZone を Temporal.Now.zonedDateTimeISO(timeZone) に渡します。
  • 推奨: `${dateStr}T${timeStr}` のような ISO 文字列を補間するのではなく、Temporal.PlainDate.from(dateStr).toPlainDateTime(timeStr).toZonedDateTime(tz) や Temporal.PlainDate.from(dateStr).toZonedDateTime({ timeZone: tz, plainTime: timeStr }) などの Temporal メソッドを使用して、別々の日付文字列と時刻文字列を結合します。
  • 推奨事項: 1 時間を超える期間をバランスよく表示する場合は、.since()、.until()、.round() で largestUnit('day'、'month'、'year' など)を渡します。ZonedDateTime の差分メソッドはデフォルトで 'hour' になります。
  • 2 つの期間を比較する場合は Temporal.Duration.compare(d1, d2, { relativeTo }) を使用し、いずれかの期間にカレンダー単位(months、years)またはタイムゾーン対応の日が含まれる場合は、relativeTo アンカーを指定します。
  • ${duration.years}y ${duration.months}m... を手動で補間するのではなく、duration.toLocaleString() または new Intl.DurationFormat(locale, { style })('long'、'short'、'narrow'、'digital')を使用して、ユーザー表示用の期間をフォーマットする。
  • 推奨: duration.toString() は、機械で読み取り可能な ISO 8601 期間文字列(例: PT1H30M)に使用します。ユーザー向けのコピーには使用しないでください。
  • PlainTime を HH:MM としてレンダリングする(例: <input type="time"> の場合)には、文字列 .slice(0, 5) ではなく .toPlainTime().toString({ smallestUnit: 'minute' }) を使用します。
  • インスタンスを直接変更しないでください。Temporal オブジェクトは不変です。.add()、.subtract()、.round()、.with() などのオペレーションは、新しいインスタンスを返します。
  • 差分自体に Date / .getTime() 演算を使用しないでください。Date にはタイムゾーン対応のカレンダー演算がなく、ミリ秒単位の精度しかありません。
  • 推奨: 計算された Temporal.Duration(または Temporal.ZonedDateTime.compare())のネイティブ .sign プロパティを使用して、期間が過去 / 期限切れの時点(sign < 0)を表しているか、将来の保留中の開始を表しているかを確認します。

代替戦略

Temporal のフォールバックとブラウザ サポート

ネイティブの Temporal API をまだサポートしていないブラウザの場合は、機能検出を使用して temporal-polyfill(約 15 KB min+gz)を動的に読み込みます。

temporal-polyfill をインポートしても、Temporal オブジェクトがグローバル スコープに自動的に割り当てられるわけではありません。コードが globalThis.Temporal に依存している場合は、明示的に割り当ててください。

// Check if Temporal is supported natively
(async () => {
  if (typeof Temporal === 'undefined') {
    // Load the polyfill conditionally
    const module = await import('https://esm.sh/temporal-polyfill');
    globalThis.Temporal = module.Temporal;
    // Extend Date.prototype if needed
    Date.prototype.toTemporalInstant = module.toTemporalInstant;
  }
  initializeApp();
})();

Intl.DurationFormat