Web Totals

Học từ Facebook #71: Engine múi giờ IANA trên client, phát hiện lệch giờ

Facebook mang theo luật tzdata từ 2003, tự tính lần đổi giờ, đoán múi giờ bằng cây quyết định và bù đồng hồ bị chỉnh tay. Luật "Fri<=1" của Israel bị lệch một tháng.

hunghg255hunghg25530 min read
#facebook#timezone#datetime#intl#javascript
Học từ Facebook #71: Engine múi giờ IANA trên client, phát hiện lệch giờ

Hiển thị "đăng lúc 14:05" nghe đơn giản: lấy timestamp, cộng offset của người xem. Khó ở chỗ offset thay đổi theo ngày (giờ mùa hè), theo năm (chính phủ đổi luật), và người xem có thể đang ở múi giờ khác với múi giờ máy tính đang để.

Ngày nay Intl.DateTimeFormat lo gần hết việc này. Nhưng bundle của Facebook vẫn mang theo một engine múi giờ hoàn chỉnh: luật tzdata nén từ năm 2003, code tự tính từng lần đổi giờ, một cây quyết định để đoán tên múi giờ chỉ từ Date, và một đoạn tự sửa offset khi đồng hồ máy bị chỉnh tay.

Bài này đọc engine đó, tìm ra một lỗi tính ngày làm sai giờ mùa hè của Israel trong 6 năm, rồi viết lại một bản nhỏ và đối chiếu với Intl.

Tip

Tóm tắt

  • Dữ liệu TimezoneRulesFrom2003-tz2020a lưu luật theo cú pháp tzdata nén: "2005 8 4 Fri<=1 2 1" nghĩa là từ 2005, trong 8 năm, tháng 4, thứ Sáu cuối cùng trên hoặc trước ngày 1, lúc 2:00, cộng 1 giờ. TimezoneDatabaseUtil biến luật thành danh sách lần đổi giờ.
  • evalDayExpr chỉ trả ngày trong tháng. Dạng >= tràn sang tháng sau được sửa tháng, dạng <= lùi về tháng trước thì không. Luật <= duy nhất trong dữ liệu là của Israel 2005–2012: 6/8 năm, ngày bắt đầu giờ mùa hè bị tính trễ đúng một tháng (2007: 30/4 thay vì 30/3).
  • Không có Intl, Facebook đoán múi giờ bằng cây quyết định: mỗi nút hỏi getTimezoneOffset() tại một thời điểm, rẽ nhánh theo câu trả lời. Lần đổi giờ của môi trường được dò bằng bước 30 ngày rồi tìm nhị phân.
  • getBrowserGMTOffsetAdjustedForSkew so đồng hồ máy với giờ server, làm tròn độ lệch tới 15 phút rồi cộng vào offset: máy để múi UTC nhưng chỉnh kim cho đúng giờ Hà Nội vẫn được nhận ra là UTC+7.

#Facebook làm thế nào?

#Dữ liệu: luật tzdata nén

Module TimezoneRulesFrom2003-tz2020a (thư mục 12.Timezone) có version: "2020a", fromYear: 2003 và một mảng ruleSets, mỗi phần tử là một chuỗi các luật sáu trường. Luật của Israel:

… 2004 1 4 7 1 1  2004 1 9 22 1 0
2005 8 4 Fri<=1 2 1
2005 1 10 9 2 0  2006 1 10 1 2 0  2007 1 9 16 2 0  …  2012 1 9 23 2 0
2013 - 3 Fri>=23 2 1  2013 - 10 lastSun 2 0
text

Sáu trường là: năm bắt đầu, số năm (- là mãi mãi), tháng, biểu thức ngày, giờ, phần cộng thêm (giờ). Biểu thức ngày có bốn dạng giống hệt tzdata: số (15), lastSun, Sun>=8, Fri<=1. LazyTimezoneDatabase nạp module dữ liệu theo fromYear và version, nên trang chỉ cần luật từ 2003 không phải tải cả lịch sử từ 1970.

#TimezoneDatabaseUtil: từ luật ra lần đổi giờ

Module này (trong 32.js) có các hàm parseOffset, parseDayExpr, evalDayExpr, getActiveRules, generateTransitionForRule, restrictTransitions, compactifyTransitions. Hai hàm quan trọng nhất:

function evalDayExpr(expr, year, month) {
  if (expr.dayOfMonth != null) return expr.dayOfMonth;
  if (expr.lastDayOfWeek != null) { /* … */ }
  if (expr.beforeOrOn != null) {
    const d = LocalDate.create(year, month, expr.beforeOrOn).addDays(1);
    const k = mod(expr.dayOfWeek - d.getDayOfWeek(), 7);
    return d.addDays(k - 7).getDayOfMonth();     // ← chỉ trả ngày trong tháng
  }
  if (expr.afterOrOn != null) { /* … */ return d.addDays(k).getDayOfMonth(); }
}

// getActiveRules
let day = evalDayExpr(rule.day, y, rule.month), month = rule.month;
rule.day.afterOrOn != null && day < rule.day.afterOrOn && month++;  // sửa tháng cho ">="
const utcMidnight = LocalDate.create(y, month, day).toInstant(Timezone.UTC);
js

Sau đó generateTransitionForRule đổi "2:00 giờ địa phương" ra UTC tuỳ kiểu giờ của luật. Với giờ địa phương (wall), nó lấy thời điểm một giây trước lần đổi, đổi ra UTC theo các lần đổi đã có, rồi cộng lại một giây: getParallelUsingTransitions(e + time - 1) + 1. Mẹo này tránh được giờ mơ hồ ngay tại lúc đổi. Cuối cùng compactifyTransitions bỏ các lần "đổi" mà offset không đổi.

#Bên trong: lỗi "Fri<=1"

flowchart TD
  R["luật: tháng 4, Fri<=1"] --> E["evalDayExpr 2007"]
  E --> D["thứ Sáu trên hoặc trước 1/4/2007 = 30/3"]
  D --> G["getDayOfMonth = 30"]
  G --> M{"dạng >= và 30 < 1?"}
  M -->|"không phải >=, giữ tháng 4"| W["LocalDate 2007-04-30"]
  W --> X["giờ mùa hè bắt đầu trễ một tháng"]

evalDayExpr tính đúng ngày (30/3/2007) nhưng chỉ trả về số 30. getActiveRules chỉ sửa tháng khi luật là dạng >= và ngày tràn sang tháng sau. Dạng <= lùi sang tháng trước thì tháng vẫn là 4, nên lần đổi giờ thành 30/4.

Tìm trong cả file dữ liệu, <= chỉ xuất hiện đúng một lần: luật Israel 2005–2012. Năm nào 1/4 là thứ Sáu (2005, 2011) thì không sao. Sáu năm còn lại bị lệch:

Năm Đúng (tzdata) Theo cách tính trên
2006 31/3 31/4, có lẽ thành 1/5 khi đổi ra instant
2007 30/3 30/4
2008 28/3 28/4
2009 27/3 27/4
2010 26/3 26/4
2012 30/3 30/4

Bảng này có được bằng cách port evalDayExpr và getActiveRules sang TypeScript (phần 1 của demo), giả định LocalDate đổi ngày không hợp lệ bằng cách tràn như Date.UTC. Ảnh hưởng thực tế có lẽ nhỏ: chỉ các timestamp cũ ở Israel trong khoảng một tháng mỗi năm đó, và chỉ khi trang dùng engine này thay vì Intl. Nhưng đây là kiểu lỗi chỉ lộ ra khi đối chiếu với một nguồn độc lập.

#Đoán múi giờ không cần Intl

Module Timezone (cùng thư mục) lấy tên múi giờ theo thứ tự: tên từ Intl.DateTimeFormat().resolvedOptions().timeZone (sau một gatekeeper), nếu không có thì đi cây quyết định:

var j = function (a) { return new Date(1e3 * a).getTimezoneOffset() * -60; };
function t() { return TimezoneUtil.determineTimezoneID(EnvironmentTimezoneDecisionTree, j) || i; }
js

Cây EnvironmentTimezoneDecisionTree-tz2020a là object lồng nhau:

{ instant: 1132117200,                 // 16/11/2005
  0:     { instant: 1531536300, 0: { timezone: 0 }, 3600: { … }, 7200: { timezone: 300 } },
  10800: { instant: 1087737300, … },
  … }
js

determineTimezoneID hỏi offset tại instant, đi vào nhánh có khoá bằng câu trả lời, lặp tới khi gặp lá {timezone: id}. Vài câu hỏi là đủ phân biệt hàng trăm múi giờ.

Khi không có luật cho múi giờ của môi trường, extractTimezoneTransitions dò từ 2004 tới 2107 với bước 30 ngày. Thấy offset hai đầu bước khác nhau thì tìm nhị phân tới đúng giây đổi giờ. Đó là cách lấy được lịch đổi giờ của máy chỉ bằng Date.

#Bù đồng hồ bị chỉnh tay

CometTimezoneUpdater chạy khi trang tải. Server nhúng sẵn server_time, server_gmt_offset và timezone đã lưu của người dùng:

e = -getBrowserGMTOffsetAdjustedForSkew(server_time);
var f = killswitch("TIMEZONE_SET_IANA_ZONE_NAME") ? null : getBrowserTimezone();
(force_update === true || e !== server_gmt_offset || (f != null && f !== timezone)) &&
  cometAsyncFetch("/ajax/timezone/update.php", { data: { gmt_off: e, is_forced: force_update, tz: f }, method: "POST" });
js

Còn getBrowserGMTOffsetAdjustedForSkew (trong 278.js) làm phép tính:

var skew = serverTime - Date.now() / 1000;
var rounded = Math.round(skew / (15 * 60)) * 15;        // phút, bội số của 15
var c = Math.round(new Date().getTimezoneOffset() + rounded) % (24 * 60);
c > 12 * 60 ? c -= 24 * 60 : c < -14 * 60 && (c += 24 * 60);
js

Lý do: nhiều người để máy ở múi giờ sai (UTC, hoặc múi giờ của nơi mua máy) rồi chỉnh kim đồng hồ cho khớp giờ địa phương. Offset máy báo là sai, nhưng độ lệch giữa đồng hồ máy và server cho biết offset thật. Làm tròn 15 phút vì mọi offset trên thế giới đều là bội số của 15 phút (Nepal +5:45, Chatham +12:45), và vì đồng hồ máy vốn đã lệch vài giây tới vài phút.

#Viết lại

Tất cả trong src/lib/tzEngine.ts. Hàm tính ngày trả ngày đầy đủ, để Date.UTC lo phần tràn tháng theo cả hai chiều:

src/lib/tzEngine.ts
/** Ngày thật của biểu thức trong (năm, tháng). Kết quả có thể nằm ở tháng trước/sau. */
export function resolveDay(expr: DayExpr, year: number, month: number): YMD {
  let day: number;
  switch (expr.kind) {
    case 'dom':
      day = expr.day;
      break;
    case 'last': {
      const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
      day = lastDay - mod(dowOf(year, month, lastDay) - expr.dow, 7);
      break;
    }
    case '>=':
      day = expr.day + mod(expr.dow - dowOf(year, month, expr.day), 7);
      break;
    case '<=':
      day = expr.day - mod(dowOf(year, month, expr.day) - expr.dow, 7);
      break;
  }
  // Date.UTC tự tràn: ngày 0 = cuối tháng trước, ngày 32 = đầu tháng sau
  const d = new Date(Date.UTC(year, month - 1, day));
  return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
}
ts

Tính lần đổi giờ theo thứ tự thời gian, mang theo phần DST đang bật để đổi giờ địa phương ra UTC:

src/lib/tzEngine.ts
/** Mọi lần chuyển giờ trong [fromYear, toYear], tính tuần tự để biết DST đang bật trước mỗi lần. */
export function transitions(zone: Zone, fromYear: number, toYear: number, resolve: DayResolver = resolveDay) {
  const items: { local: YMD; localMs: number; r: Rule }[] = [];
  for (let y = fromYear - 1; y <= toYear; y++) {
    for (const r of zone.rules) {
      if (y < r.from || y > r.to) continue;
      const local = resolve(r.day, y, r.month);
      items.push({ local, localMs: Date.UTC(local.year, local.month - 1, local.day) + r.at * 1000, r });
    }
  }
  items.sort((a, b) => a.localMs - b.localMs);
  const out: Transition[] = [];
  let save = 0;
  for (const { local, localMs, r } of items) {
    const shift = r.atType === 'u' ? 0 : r.atType === 's' ? zone.std : zone.std + save;
    const at = localMs - shift * 1000;
    save = r.save;
    if (local.year >= fromYear) out.push({ at, offset: zone.std + save, save, local });
  }
  return out;
}
ts

Phần dò lần đổi giờ (extractTransitions) và cây quyết định (buildTzTree, guessZone) nằm cuối file:

Xem toàn bộ src/lib/tzEngine.ts
src/lib/tzEngine.ts
/**
 * Engine múi giờ nhỏ chạy trên client (ý tưởng từ TimezoneDatabaseUtil, TimezoneUtil và CometTimezoneUpdater của Facebook).
 *
 * Luật DST viết theo cú pháp tzdata: "2005 2012 Apr Fri<=1 2:00 1:00" nghĩa là từ 2005 tới 2012,
 * tháng 4, thứ Sáu cuối cùng trên hoặc trước ngày 1, lúc 2:00 giờ địa phương, cộng thêm 1 giờ.
 *
 * Khác bản gốc: hàm tính ngày trả về NGÀY ĐẦY ĐỦ (năm, tháng, ngày) thay vì chỉ "ngày trong tháng",
 * nên "Fri<=1" rơi sang tháng trước vẫn đúng. Bản gốc chỉ sửa tháng cho dạng ">=".
 * Mỗi múi giờ chỉ có một "era" (một offset chuẩn) để ví dụ gọn.
 */

const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const mod = (a: number, n: number) => ((a % n) + n) % n;

/** "+5:30" → 19800 giây; "-3" → -10800; "2:00" → 7200 */
export function parseOffset(text: string): number {
  const m = /^([+-]?)(\d+)(?::(\d+))?(?::(\d+))?$/.exec(text.trim());
  if (!m) throw new SyntaxError(`Offset không hợp lệ: ${text}`);
  const sign = m[1] === '-' ? -1 : 1;
  return sign * (+m[2] * 3600 + +(m[3] ?? 0) * 60 + +(m[4] ?? 0));
}

export type DayExpr =
  | { kind: 'dom'; day: number }
  | { kind: 'last'; dow: number }
  | { kind: '>=' | '<='; dow: number; day: number };

export function parseDayExpr(text: string): DayExpr {
  let m: RegExpExecArray | null;
  if ((m = /^(\w{3})(>=|<=)(\d+)$/.exec(text)) && DAYS.includes(m[1])) {
    return { kind: m[2] as '>=' | '<=', dow: DAYS.indexOf(m[1]), day: +m[3] };
  }
  if ((m = /^last(\w{3})$/.exec(text)) && DAYS.includes(m[1])) return { kind: 'last', dow: DAYS.indexOf(m[1]) };
  if (/^\d+$/.test(text)) return { kind: 'dom', day: +text };
  throw new SyntaxError(`Biểu thức ngày không hợp lệ: ${text}`);
}

export interface YMD {
  year: number;
  month: number; // 1–12
  day: number;
}

const dowOf = (y: number, m: number, d: number) => new Date(Date.UTC(y, m - 1, d)).getUTCDay();

/** Ngày thật của biểu thức trong (năm, tháng). Kết quả có thể nằm ở tháng trước/sau. */
export function resolveDay(expr: DayExpr, year: number, month: number): YMD {
  let day: number;
  switch (expr.kind) {
    case 'dom':
      day = expr.day;
      break;
    case 'last': {
      const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
      day = lastDay - mod(dowOf(year, month, lastDay) - expr.dow, 7);
      break;
    }
    case '>=':
      day = expr.day + mod(expr.dow - dowOf(year, month, expr.day), 7);
      break;
    case '<=':
      day = expr.day - mod(dowOf(year, month, expr.day) - expr.dow, 7);
      break;
  }
  // Date.UTC tự tràn: ngày 0 = cuối tháng trước, ngày 32 = đầu tháng sau
  const d = new Date(Date.UTC(year, month - 1, day));
  return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
}

export type DayResolver = (expr: DayExpr, year: number, month: number) => YMD;

export interface Rule {
  from: number;
  to: number;
  month: number;
  day: DayExpr;
  /** giây kể từ nửa đêm */
  at: number;
  /** 'w' giờ địa phương đang chạy, 's' giờ chuẩn (bỏ DST), 'u' UTC */
  atType: 'w' | 's' | 'u';
  save: number;
}

/** Đọc một dòng luật kiểu tzdata: "FROM TO MON DAY AT SAVE" (TO có thể là only/max). */
export function rule(line: string): Rule {
  const [from, to, mon, day, at, save] = line.trim().split(/\s+/);
  const atType = /[us]$/.test(at) ? (at.slice(-1) as 'u' | 's') : 'w';
  return {
    from: +from,
    to: to === 'only' ? +from : to === 'max' ? 9999 : +to,
    month: MONTHS.indexOf(mon) + 1,
    day: parseDayExpr(day),
    at: parseOffset(at.replace(/[usw]$/, '')),
    atType,
    save: parseOffset(save),
  };
}

export interface Zone {
  name: string;
  std: number; // offset chuẩn, giây
  rules: Rule[];
}

export interface Transition {
  at: number; // ms UTC
  offset: number; // giây, sau chuyển
  save: number;
  local: YMD;
}

/** Mọi lần chuyển giờ trong [fromYear, toYear], tính tuần tự để biết DST đang bật trước mỗi lần. */
export function transitions(zone: Zone, fromYear: number, toYear: number, resolve: DayResolver = resolveDay) {
  const items: { local: YMD; localMs: number; r: Rule }[] = [];
  for (let y = fromYear - 1; y <= toYear; y++) {
    for (const r of zone.rules) {
      if (y < r.from || y > r.to) continue;
      const local = resolve(r.day, y, r.month);
      items.push({ local, localMs: Date.UTC(local.year, local.month - 1, local.day) + r.at * 1000, r });
    }
  }
  items.sort((a, b) => a.localMs - b.localMs);
  const out: Transition[] = [];
  let save = 0;
  for (const { local, localMs, r } of items) {
    const shift = r.atType === 'u' ? 0 : r.atType === 's' ? zone.std : zone.std + save;
    const at = localMs - shift * 1000;
    save = r.save;
    if (local.year >= fromYear) out.push({ at, offset: zone.std + save, save, local });
  }
  return out;
}

/** Offset (giây) đang áp dụng tại thời điểm UTC `ms`. */
export function offsetAt(zone: Zone, ms: number, resolve: DayResolver = resolveDay): number {
  const y = new Date(ms).getUTCFullYear();
  const list = transitions(zone, y - 1, y, resolve);
  let offset = zone.std;
  for (const t of list) if (t.at <= ms) offset = t.offset;
  return offset;
}

// ---------------- dữ liệu mẫu (rút gọn từ tzdata) ----------------

const ISRAEL_END: [number, string, string][] = [
  [2005, 'Oct', '9'],
  [2006, 'Oct', '1'],
  [2007, 'Sep', '16'],
  [2008, 'Oct', '5'],
  [2009, 'Sep', '27'],
  [2010, 'Sep', '12'],
  [2011, 'Oct', '2'],
  [2012, 'Sep', '23'],
];

export const ZONES: Record<string, Zone> = {
  'Asia/Jerusalem': {
    name: 'Asia/Jerusalem',
    std: 7200,
    rules: [
      rule('2005 2012 Apr Fri<=1 2:00 1:00'),
      ...ISRAEL_END.map(([y, m, d]) => rule(`${y} only ${m} ${d} 2:00 0`)),
      rule('2013 max Mar Fri>=23 2:00 1:00'),
      rule('2013 max Oct lastSun 2:00 0'),
    ],
  },
  'America/New_York': {
    name: 'America/New_York',
    std: -18000,
    rules: [rule('2007 max Mar Sun>=8 2:00 1:00'), rule('2007 max Nov Sun>=1 2:00 0')],
  },
  'Europe/Berlin': {
    name: 'Europe/Berlin',
    std: 3600,
    rules: [rule('1996 max Mar lastSun 1:00u 1:00'), rule('1996 max Oct lastSun 1:00u 0')],
  },
  'Australia/Sydney': {
    name: 'Australia/Sydney',
    std: 36000,
    rules: [rule('2008 max Apr Sun>=1 2:00s 0'), rule('2008 max Oct Sun>=1 2:00s 1:00')],
  },
  'Asia/Ho_Chi_Minh': { name: 'Asia/Ho_Chi_Minh', std: 25200, rules: [] },
};

// ---------------- so với trình duyệt ----------------

/** Offset (giây) theo dữ liệu tz có sẵn trong trình duyệt, để đối chiếu. */
const formatters = new Map<string, Intl.DateTimeFormat>();
export function intlOffset(timeZone: string, ms: number): number {
  let f = formatters.get(timeZone);
  if (!f) formatters.set(timeZone, (f = new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName: 'longOffset' })));
  const name = f
    .formatToParts(ms)
    .find((p) => p.type === 'timeZoneName')?.value;
  const m = /GMT([+-])(\d{2}):(\d{2})/.exec(name ?? '');
  return m ? (m[1] === '-' ? -1 : 1) * (+m[2] * 3600 + +m[3] * 60) : 0;
}

export const formatOffset = (sec: number) => {
  const sign = sec < 0 ? '-' : '+';
  const a = Math.abs(sec);
  return `UTC${sign}${Math.floor(a / 3600)}${a % 3600 ? ':' + String((a % 3600) / 60).padStart(2, '0') : ''}`;
};

/** Tên IANA của trình duyệt, null nếu không có Intl (bản gốc cũng bọc try/catch). */
export function browserTimeZone(): string | null {
  try {
    return Intl.DateTimeFormat().resolvedOptions().timeZone || null;
  } catch {
    return null;
  }
}

/**
 * Offset thật của người dùng (phút, dương = phía đông UTC) khi đồng hồ máy có thể bị chỉnh tay.
 * Máy để múi UTC nhưng chỉnh kim cho khớp giờ Hà Nội thì đồng hồ "nhanh" 7 giờ so với server:
 * lấy độ lệch đó, làm tròn 15 phút, cộng vào offset trình duyệt báo.
 */
export function effectiveOffsetMinutes(serverMs: number, clientMs: number, browserOffsetMin: number): number {
  const skew = Math.round((clientMs - serverMs) / 60_000 / 15) * 15;
  let off = (browserOffsetMin + skew) % (24 * 60);
  // hợp lệ từ UTC-12 tới UTC+14 (Kiribati), ngoài khoảng đó thì quay vòng 24 giờ
  if (off < -12 * 60) off += 24 * 60;
  else if (off > 14 * 60) off -= 24 * 60;
  return off;
}

// ---------------- dò và đoán múi giờ từ môi trường ----------------

export type OffsetProbe = (ms: number) => number;

/**
 * Dò các lần đổi giờ chỉ bằng một hàm "offset tại thời điểm t" (Date.getTimezoneOffset hay Intl):
 * bước nhảy thô `step`, thấy offset khác thì tìm nhị phân tới đúng mili giây (như extractTimezoneTransitions).
 */
export function extractTransitions(probe: OffsetProbe, startMs: number, endMs: number, step = 7 * 86_400_000) {
  const out: { at: number; offset: number }[] = [];
  let prev = probe(startMs);
  for (let t = startMs; t < endMs; t += step) {
    const next = probe(t + step);
    if (next === prev) continue;
    let lo = t; // probe(lo) === prev
    let hi = t + step; // probe(hi) === next
    while (hi - lo > 1) {
      const mid = Math.floor((lo + hi) / 2);
      if (probe(mid) === prev) lo = mid;
      else hi = mid;
    }
    if (hi < endMs) out.push({ at: hi, offset: next });
    prev = next;
  }
  return out;
}

/** Cây quyết định: hỏi offset tại `at`, rẽ nhánh theo kết quả; lá là nhóm múi giờ không phân biệt được. */
export type TzTree = { at: number; branches: Record<string, TzTree> } | { zones: string[] };

export function buildTzTree(zones: string[], probes: number[], offset: (zone: string, ms: number) => number): TzTree {
  const table = new Map(zones.map((z) => [z, probes.map((t) => offset(z, t))]));
  const grow = (ids: string[]): TzTree => {
    let best = -1;
    let bestWorst = ids.length;
    probes.forEach((_, i) => {
      const sizes = new Map<number, number>();
      for (const z of ids) sizes.set(table.get(z)![i], (sizes.get(table.get(z)![i]) ?? 0) + 1);
      const worst = Math.max(...sizes.values()); // nhóm lớn nhất sau khi hỏi câu này
      if (worst < bestWorst) [best, bestWorst] = [i, worst];
    });
    if (best < 0) return { zones: ids }; // không câu hỏi nào tách được nữa
    const groups: Record<string, string[]> = {};
    for (const z of ids) (groups[table.get(z)![best]] ??= []).push(z);
    const branches: Record<string, TzTree> = {};
    for (const [k, g] of Object.entries(groups)) branches[k] = grow(g);
    return { at: probes[best], branches };
  };
  return grow(zones);
}

export function guessZone(tree: TzTree, probe: OffsetProbe): { zones: string[]; questions: number } {
  let questions = 0;
  while ('at' in tree) {
    const next = tree.branches[String(probe(tree.at))];
    questions++;
    if (!next) return { zones: [], questions };
    tree = next;
  }
  return { zones: tree.zones, questions };
}
ts

Khác bản gốc:

  • resolveDay trả {year, month, day} nên <=, >= và last đều đúng khi rơi sang tháng khác. Không cần sửa tháng ở nơi gọi.
  • Luật được viết bằng cú pháp tzdata gốc (rule('2005 2012 Apr Fri<=1 2:00 1:00')), dễ đối chiếu với file asia của tzdata.
  • Mỗi múi giờ chỉ có một offset chuẩn, không có "era" như bản gốc. Đủ cho các múi giờ không đổi offset chuẩn trong khoảng thời gian quan tâm.
  • buildTzTree dựng cây từ chính dữ liệu Intl của trình duyệt (chọn câu hỏi làm nhóm lớn nhất còn lại nhỏ nhất), thay vì nhúng cây dựng sẵn. Lá là nhóm múi giờ không phân biệt được, không phải một ID.
  • effectiveOffsetMinutes nhận đồng hồ server/client làm tham số và trả offset dương về phía đông, cùng chiều với cách người ta viết "UTC+7".

#Đi qua code

  • resolveDay cho <=: lấy thứ của ngày mốc, lùi mod(thứ mốc − thứ cần, 7) ngày. Kết quả có thể là ngày 0 hoặc âm, Date.UTC(2007, 3, 0) tự hiểu là 31/3.
  • transitions lấy luật của cả năm trước fromYear để biết DST đang bật hay tắt ở đầu khoảng (cần cho Nam bán cầu: Sydney đầu năm đang ở giờ mùa hè). Sắp xếp theo giờ địa phương rồi đi một lượt, mỗi lần đổi dùng save của lần trước để đổi giờ w ra UTC.
  • Hậu tố u (UTC) và s (giờ chuẩn) đi theo tzdata: châu Âu đổi lúc 1:00u cho mọi nước cùng lúc, Sydney đổi lúc 2:00s.
  • offsetAt tính lần đổi giờ của năm đó và năm trước, lấy lần cuối cùng không sau ms.
  • extractTransitions bước 7 ngày (không lần đổi giờ nào gần nhau hơn thế), mỗi lần thấy offset khác thì tìm nhị phân giữa hai đầu bước: khoảng 30 lần gọi probe cho mỗi lần đổi.
  • buildTzTree dựng sẵn bảng offset của mọi múi giờ tại mọi mốc, rồi đệ quy: mỗi tầng chọn mốc chia nhóm tốt nhất. Với 418 múi giờ của Chromium/Node và 192 mốc (ngày 1 và 15 mỗi tháng 2020–2027), cây sâu tối đa 4 tầng, dựng mất khoảng 300 ms.

Đối chiếu: với năm múi giờ mẫu (Jerusalem, New York, Berlin, Sydney, Hồ Chí Minh), offsetAt khớp Intl ở 157.780 thời điểm cách nhau 5 giờ từ 2009 tới 2026, và mọi lần đổi giờ 2024–2026 khớp tới từng giây.

#Tại sao không làm cách khác?

  • Chỉ dùng Intl: đây là lựa chọn đúng cho dự án mới. Có lẽ Facebook giữ engine riêng vì nó có từ trước khi Intl có timeZone trên mọi trình duyệt, và vì họ cần cùng một phiên bản tzdata ở client và server (server định dạng sẵn một phần giờ trong HTML, client phải ra cùng kết quả). Trình duyệt cập nhật tzdata theo lịch riêng.
  • getTimezoneOffset() một lần: chỉ cho offset hiện tại. Lưu offset đó lên server rồi dùng cho mọi timestamp là sai với mọi thời điểm bên kia lần đổi giờ.
  • Thư viện như moment-timezone: dữ liệu đầy đủ nặng hàng trăm KB. Facebook cắt dữ liệu theo năm (fromYear) và nạp lười.
  • Tin offset máy báo: sai với người chỉnh kim đồng hồ. Tin giờ server và bỏ qua offset máy cũng sai, vì không biết người dùng ở đâu. Kết hợp hai nguồn như getBrowserGMTOffsetAdjustedForSkew là cách rẻ mà hiệu quả.

#Edge case và cạm bẫy

  • Giờ không tồn tại và giờ lặp. 2:30 sáng 29/3/2026 không có ở Berlin, 2:30 sáng 25/10/2026 có hai lần. Đổi "giờ địa phương" ra UTC phải chọn một quy ước (Temporal gọi là disambiguation: 'compatible' | 'earlier' | 'later' | 'reject').
  • Offset không tròn giờ: +5:30 (Ấn Độ), +5:45 (Nepal), −3:30 (Newfoundland). Đừng lưu offset bằng số giờ nguyên.
  • Chính phủ đổi luật. Dữ liệu tz2020a không biết các thay đổi sau đó. Dữ liệu nhúng sẵn cần cập nhật như một dependency.
  • Làm tròn 15 phút nghĩa là đồng hồ lệch 8 phút sẽ bị coi là lệch 15 phút. Ngưỡng đó vẫn hợp lý: đồng hồ lệch 8 phút thì cũng nên báo người dùng.
  • Cây quyết định nhận diện "hành vi", không phải tên. Hai múi giờ có cùng offset ở mọi mốc hỏi sẽ nằm chung lá. Berlin nằm chung nhóm với 32 múi giờ châu Âu khác, và từ 2020 tới nay chúng đúng là giống nhau. Muốn phân biệt Jerusalem với Athens thì cần mốc rơi đúng vào mấy ngày lệch nhau quanh cuối tháng 3.

#Dùng trong dự án

Đồng bộ múi giờ người dùng lên server khi trang tải, giống CometTimezoneUpdater:

const { serverTime, savedOffsetMin, savedZone } = window.__BOOT__;
const offset = effectiveOffsetMinutes(serverTime, Date.now(), -new Date().getTimezoneOffset());
const zone = browserTimeZone();
if (offset !== savedOffsetMin || (zone && zone !== savedZone)) {
  navigator.sendBeacon('/api/timezone', JSON.stringify({ offset, zone }));
}
ts

Định dạng giờ thì dùng Intl, chỉ dùng engine tự viết khi cần chạy ở nơi không có Intl đầy đủ hoặc cần khớp tzdata với server:

new Intl.DateTimeFormat('vi-VN', { timeZone: user.zone, dateStyle: 'medium', timeStyle: 'short' }).format(post.createdAt);
ts

#Kiểm thử

Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it } from 'vitest';
import {
  ZONES,
  buildTzTree,
  effectiveOffsetMinutes,
  extractTransitions,
  guessZone,
  intlOffset,
  offsetAt,
  parseDayExpr,
  parseOffset,
  resolveDay,
  transitions,
} from '../src/lib/tzEngine';

describe('tzEngine', () => {
  it('parse offset và biểu thức ngày', () => {
    expect(parseOffset('+5:30')).toBe(19800);
    expect(parseOffset('-3')).toBe(-10800);
    expect(parseDayExpr('Fri<=1')).toEqual({ kind: '<=', dow: 5, day: 1 });
    expect(parseDayExpr('lastSun')).toEqual({ kind: 'last', dow: 0 });
  });

  it('Fri<=1 tháng 4/2007 là 30/3', () => {
    expect(resolveDay(parseDayExpr('Fri<=1'), 2007, 4)).toEqual({ year: 2007, month: 3, day: 30 });
    expect(resolveDay(parseDayExpr('Sun>=29'), 2026, 2)).toEqual({ year: 2026, month: 3, day: 1 });
  });

  it('khớp Intl ở mọi lần đổi giờ 2024–2026', () => {
    for (const [name, zone] of Object.entries(ZONES)) {
      for (const t of transitions(zone, 2024, 2026)) {
        expect(intlOffset(name, t.at)).toBe(t.offset);
        expect(intlOffset(name, t.at - 1000)).not.toBe(t.offset);
      }
    }
  });

  it('Israel 2007: 15/4 đã là UTC+3', () => {
    expect(offsetAt(ZONES['Asia/Jerusalem'], Date.UTC(2007, 3, 15, 12))).toBe(3 * 3600);
  });

  it('dò lần đổi giờ bằng tìm nhị phân', () => {
    const r = extractTransitions((ms) => intlOffset('Europe/Berlin', ms), Date.UTC(2026, 0, 1), Date.UTC(2027, 0, 1));
    expect(r.map((x) => new Date(x.at).toISOString())).toEqual(['2026-03-29T01:00:00.000Z', '2026-10-25T01:00:00.000Z']);
  });

  it('cây quyết định đưa New York về nhóm có New York', () => {
    const probes = Array.from({ length: 48 }, (_, i) => Date.UTC(2024 + Math.floor(i / 24), (i % 24) >> 1, i % 2 ? 15 : 1));
    const zones = ['America/New_York', 'America/Chicago', 'Europe/Berlin', 'Asia/Ho_Chi_Minh', 'Australia/Sydney'];
    const tree = buildTzTree(zones, probes, intlOffset);
    expect(guessZone(tree, (ms) => intlOffset('America/New_York', ms)).zones).toEqual(['America/New_York']);
  });

  it('bù đồng hồ chỉnh tay, làm tròn 15 phút', () => {
    expect(effectiveOffsetMinutes(0, 7 * 3600_000, 0)).toBe(420);
    expect(effectiveOffsetMinutes(0, 7 * 3600_000 + 90_000, 0)).toBe(420);
    expect(effectiveOffsetMinutes(0, 3 * 3600_000, 13 * 60)).toBe(-8 * 60); // UTC+16 quay vòng
  });
});
ts

#So với thư viện và chuẩn web

  • Intl.DateTimeFormat với timeZone và timeZoneName: 'longOffset' cho offset của bất kỳ múi giờ IANA nào tại bất kỳ thời điểm nào. Intl.supportedValuesOf('timeZone') liệt kê các múi giờ trình duyệt biết.
  • Temporal: Temporal.ZonedDateTime gói một thời điểm cùng múi giờ, tự xử lý giờ không tồn tại và giờ lặp; zdt.getTimeZoneTransition('next') trả lần đổi giờ kế tiếp, đúng việc extractTransitions làm. Temporal đã có trên Firefox và Chromium, Safari đang triển khai (tính tới 10/2026).
  • tzdata (IANA) là nguồn gốc của mọi thứ ở trên. File asia có đúng dòng Rule Zion 2005 2012 - Apr Fri<=1 2:00 1:00 D.
  • jstz và các thư viện đoán múi giờ cũ dùng ý tưởng giống cây quyết định: hỏi offset ở vài ngày mốc. Ngày nay resolvedOptions().timeZone thay được gần hết, trừ khi người dùng chỉnh kim đồng hồ.

Bài này thuộc series Học từ mã nguồn Facebook (106 bài, đọc theo thứ tự).

Xem nhanh danh sách bài trong series

Phần 1

Phần 2

Phần 3

Phần 4–6

Phần 7–10

Phần 11–15

Code + demo: examples/facebook-patterns · Mở trên StackBlitz