การคำนวณส่วนต่างของเหตุการณ์และการจัดรูปแบบระยะเวลาด้วย Temporal

ฟีเจอร์บนเว็บ: Temporal Intl.DurationFormat

ในอดีต การคำนวณเวลาที่ผ่านไปหรือเวลาที่เหลืออยู่ระหว่างเหตุการณ์ (เช่น การหมดอายุของช่วงทดลองใช้ การนับถอยหลังการสมัครใช้บริการ หรือกรอบเวลา SLA) และการแสดงช่วงเวลาเหล่านั้นในรูปแบบที่แปลแล้วและมนุษย์อ่านได้นั้นต้องใช้การคำนวณด้วยตนเองหรือไลบรารีภายนอก ออบเจ็กต์ Date เดิมจะติดตามเฉพาะมิลลิวินาทีของ Epoch เท่านั้น ซึ่งทำให้การคำนวณเดือนตามปฏิทิน การเปลี่ยนเวลาออมแสง (DST) และการปรับสมดุลหน่วยมีแนวโน้มที่จะเกิดข้อผิดพลาด

API Temporal จะจับคู่ 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 เมื่อปรับสมดุลตามเดือน ปี หรือการเปลี่ยนเวลา DST ในปฏิทิน)
    • เปรียบเทียบระยะเวลา 2 ช่วงด้วย Temporal.Duration.compare(d1, d2, { relativeTo }) โดยต้องส่ง PlainDate หรือ ZonedDateTime เป็น relativeTo เมื่อระยะเวลาใดระยะเวลาหนึ่งมีหน่วยปฏิทิน (years, months, weeks) หรือข้ามขอบเขต DST
  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) เมื่อคำนวณกับเขตเวลาอื่นที่ไม่ใช่ค่าเริ่มต้นของระบบ
  • ควรรวมสตริงวันที่และเวลาที่แยกกันด้วยเมธอด Temporal เช่น Temporal.PlainDate.from(dateStr).toPlainDateTime(timeStr).toZonedDateTime(tz) หรือ Temporal.PlainDate.from(dateStr).toZonedDateTime({ timeZone: tz, plainTime: timeStr }) แทนการแทรกสตริง ISO เช่น `${dateStr}T${timeStr}`
  • ต้องส่ง largestUnit (เช่น 'day', 'month', 'year') ใน .since(), .until() หรือ .round() เมื่อต้องการปรับสมดุลระยะเวลาที่เกินชั่วโมง โดยเมธอดความแตกต่าง ZonedDateTime จะมีค่าเริ่มต้นเป็น 'hour'
  • ควรใช้ Temporal.Duration.compare(d1, d2, { relativeTo }) เมื่อเปรียบเทียบระยะเวลา 2 ช่วง โดยระบุจุดยึด relativeTo เมื่อระยะเวลาใดระยะเวลาหนึ่งเกี่ยวข้องกับหน่วยปฏิทิน (months, years) หรือวันที่อิงตามเขตเวลา
  • ควรจัดรูปแบบระยะเวลาสำหรับการแสดงผลของผู้ใช้โดยใช้ duration.toLocaleString() หรือ new Intl.DurationFormat(locale, { style }) ('long', 'short', 'narrow' หรือ 'digital') แทนการประมาณค่าในช่วง ${duration.years}y ${duration.months}m... ด้วยตนเอง
  • ควรใช้ duration.toString() สำหรับสตริงระยะเวลา ISO 8601 ที่เครื่องอ่านได้ (เช่น PT1H30M) และไม่ควรใช้เป็นข้อความที่แสดงต่อผู้ใช้
  • ควรแสดงผล PlainTime เป็น HH:MM (เช่น สำหรับ <input type="time">) โดยใช้ .toPlainTime().toString({ smallestUnit: 'minute' }) แทนสตริง .slice(0, 5)
  • อย่าแก้ไขอินสแตนซ์โดยตรง ออบเจ็กต์ Temporal เปลี่ยนแปลงไม่ได้ การดำเนินการอย่าง .add(), .subtract(), .round() หรือ .with() จะแสดงผลอินสแตนซ์ใหม่
  • อย่าใช้การคำนวณ Date / .getTime() สำหรับส่วนต่าง Date ไม่มีคณิตศาสตร์ปฏิทินที่คำนึงถึงเขตเวลาและมีความแม่นยำระดับมิลลิวินาทีเท่านั้น
  • ควรใช้พร็อพเพอร์ตี้ .sign แบบเนทีฟของ Temporal.Duration (หรือ Temporal.ZonedDateTime.compare()) ที่คำนวณแล้วเพื่อตรวจสอบว่าระยะเวลาแสดงถึงจุดเวลาที่ผ่านมา/หมดอายุ (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