Học từ Facebook #47: Giờ mở cửa theo tuần, khoảng qua đêm và phần bù
Cách Facebook Pages lưu giờ theo tuần: tách khoảng qua đêm sang hôm sau, gộp khoảng chồng, lấy phần bù, đọc giờ theo múi của Page, và ba lỗi ở biên, ngày 31/12, giờ mùa hè.
"Mở cửa 18:00 đến 02:00, thứ Sáu và thứ Bảy." Câu này người đọc hiểu ngay, nhưng máy tính phải trả lời được:
- Rạng sáng Chủ nhật lúc 01:30 quán có mở không? (Có, vì khoảng của tối thứ Bảy kéo sang.)
- Thứ Hai có hai khoảng 10:00–14:00 và 13:00–22:00 bị nhập chồng, hiển thị thế nào?
- Đang 23:30 tối thứ Sáu, "đóng cửa lúc mấy giờ, hôm nay hay ngày mai"?
- Khách ở Việt Nam xem trang của một quán ở New York: "bây giờ" là giờ của ai?
Facebook Pages có một editor cho đúng loại dữ liệu này: chọn các ngày trong tuần, giờ bắt đầu, giờ kết thúc, có thể nhiều dòng. Phần tính toán nằm trong module PagesTimeRangeEditorUtilities, ngắn và dễ mang đi dùng lại.
Tip
Tóm tắt
- Lưu mỗi khoảng là
{weekdays, fromSecInDay, toSecInDay}: giây kể từ 0:00, không lưu ngày giờ cụ thể. - Khoảng qua đêm (
from ≥ to) được tách:[from, 24:00)cho ngày đã chọn và[0:00, to)cho ngày(weekday + 1) % 7. - Theo từng ngày: lọc, sắp theo giờ bắt đầu, gộp khoảng chồng. Phần bù trong
[0, 24h)là "giờ còn lại". - Ba lỗi nhỏ của bản gốc: so sánh hở hai đầu, nhãn "ngày mai" sai vào 31/12, cộng giây từ nửa đêm lệch một giờ vào ngày đổi giờ mùa hè.
▶ Mở demo toàn màn hình ↗ · Sửa code trên StackBlitz ↗ · Xem source trên GitHub ↗
#Facebook làm thế nào?
PagesTimeRangeEditorUtilities và PagesTimeRangeEditorStore nằm trong 66.js. Tên các hàm cho thấy dữ liệu là giờ vắng mặt (away) của Page: getAwayTimeBracketsForWeekday, getIsRequestTimeInAwayTimeRange. Phép tính không quan tâm khoảng đó là giờ mở hay giờ vắng, nên bài này dùng ví dụ giờ mở cửa cho dễ hình dung.
#Dữ liệu
Mỗi dòng trong editor là một object:
{
id: guid(),
isRecurrent: true, // lặp hằng tuần (false: chỉ một ngày cụ thể, lưu ở `date`)
weekdays: Immutable.Set([5, 6]), // 0 = Chủ nhật … 6 = thứ Bảy
fromSecInDay: 64800, // 18:00
toSecInDay: 7200, // 02:00 → nhỏ hơn from: qua đêm
date: 1729990000,
}
js
Dòng mới tạo mặc định là 0 tới 86399 (23:59:59), tức "cả ngày" trừ giây cuối.
#Tách khoảng qua đêm
const DAY = 24 * 3600;
function splitOverNightTimeRanges(ranges) {
const out = [];
ranges.map((r) => {
if (!r.isRecurrent || r.fromSecInDay < r.toSecInDay) { out.push(r); return; }
out.push({ ...r, toSecInDay: DAY }); // phần trước nửa đêm
out.push({ ...r, id: r.id + '_overnight', fromSecInDay: 0, // phần sau nửa đêm…
weekdays: Immutable.Set(r.weekdays.map((d) => (d + 1) % 7)) }); // …thuộc NGÀY HÔM SAU
});
return Immutable.List(out);
}
js
(d + 1) % 7 đưa thứ Bảy (6) về Chủ nhật (0). Sau bước này mọi khoảng đều nằm gọn trong một ngày, phần còn lại chỉ là xử lý khoảng trên một trục thẳng [0, 86400).
#Gộp khoảng theo từng ngày
function getAwayTimeBracketsForWeekday(weekday, ranges) {
const sorted = splitOverNightTimeRanges(ranges)
.filter((r) => r.weekdays.contains(weekday) && r.isRecurrent)
.sort((a, b) => a.fromSecInDay - b.fromSecInDay);
const merged = [];
for (const r of sorted) {
const last = merged[merged.length - 1];
if (last && last.to >= r.fromSecInDay) last.to = Math.max(last.to, r.toSecInDay);
else merged.push({ from: r.fromSecInDay, to: r.toSecInDay });
}
return Immutable.List(merged);
}
js
Thuật toán gộp khoảng kinh điển: sắp theo điểm đầu, rồi đi một lượt; khoảng mới chồng (hoặc chạm, nhờ dấu >=) khoảng cuối thì kéo dài, không thì mở khoảng mới. Độ phức tạp O(n log n) vì bước sắp xếp.
Hàm này chỉ tính các khoảng lặp lại. Khoảng một lần (isRecurrent: false) được giữ nguyên ở bước tách nhưng bị lọc bỏ ở đây.
#Phần bù
function getReversedTimeBrackets(brackets) {
brackets = brackets.sort((a, b) => a.from - b.from);
let cursor = 0;
const out = [];
for (const b of brackets) {
if (cursor < b.from) out.push({ from: cursor, to: b.from });
cursor = b.to;
}
if (cursor < DAY) out.push({ from: cursor, to: DAY });
return Immutable.List(out);
}
js
Có giờ vắng thì phần bù là giờ có mặt, và ngược lại. cursor = b.to đúng khi đầu vào đã gộp (như kết quả của hàm trên). Đưa vào danh sách chưa gộp, ví dụ [10–20, 12–14], thì cursor lùi về 14 và tạo ra khoảng 14–20 sai; bản viết lại dùng max cho chắc.
#"Bây giờ" theo múi giờ của Page
getIsRequestTimeInAwayTimeRange(ranges, timezoneName) {
return this.isTimeInAwayTimeRange(DateTime.now(this.getTimezoneID(timezoneName)), ranges);
},
isTimeInAwayTimeRange(now, ranges) {
const brackets = this.getAwayTimeBracketsForWeekday(now.getDayOfWeek(), ranges);
for (const b of brackets) {
const from = now.startOfDay().addSeconds(b.from);
const to = now.startOfDay().addSeconds(b.to);
if (now.isAfter(from) && now.isBefore(to)) return true;
}
return false;
},
js
DateTime.now(timezoneID) lấy giờ hiện tại theo múi giờ của Page (danh sách múi lấy từ AdsTimezone), không phải múi của máy người đang xem. Đây là quyết định đúng: quán ở New York đóng cửa lúc 22:00 giờ New York, bất kể khách xem từ đâu.
#Ba lỗi nhỏ
- Biên hở hai đầu.
isAfter(from) && isBefore(to)loại cảfromlẫnto. Khoảng 07:00–22:00 thì đúng 07:00:00 bị coi là ngoài khoảng. Lỗi chỉ trong một giây, nhưng với một khoảng0 → 86399thì giây 0 và giây cuối của ngày rơi ra ngoài. - "Ngày mai" theo số thứ tự ngày trong năm. Hàm hiển thị so
today.getDayOfYear() + 1 === target.getDayOfYear(). Ngày 31/12 là ngày 365, ngày 1/1 là ngày 1, nên 365 + 1 ≠ 1 và nhãn rơi về tên thứ thay vì "ngày mai". - Cộng giây từ nửa đêm.
startOfDay().addSeconds(s)cộng một khoảng thời gian thật. Ngày đổi sang giờ mùa hè chỉ có 23 giờ, nên "0:00 + 9 giờ" là 10:00 giờ treo tường chứ không phải 09:00. Demo tính lại đúng phép này cho New York ngày 8/3/2026 bằngIntl. Lỗi này suy ra từ cáchDateTimeđược dùng; mình không có mãDateTime.addSecondstrong bản dump để xác nhận nó cộng theo thời gian thật hay theo giờ treo tường.
Thêm một chi tiết ở store: PagesTimeRangeEditorStore sửa thẳng object cũ (a.isRecurrent = !a.isRecurrent) rồi mới Object.assign({}, a) để thay vào List. Object cũ vẫn nằm trong List của lần state trước, nên state trước cũng bị đổi theo. Với Flux store chỉ phát sự kiện "đã đổi" thì không ai thấy; nhưng nếu có component so sánh state cũ/mới để bỏ qua render thì sẽ sai.
#Bên trong: khoảng trên một trục thẳng
flowchart LR
A["Các dòng nhập: ngày + giờ"] --> B["Tách khoảng qua đêm"]
B --> C["Lọc theo thứ"]
C --> D["Sắp theo giờ bắt đầu"]
D --> E["Gộp khoảng chồng"]
E --> F["Giờ mở trong ngày"]
E --> G["Phần bù: giờ đóng"]
F --> H{"Giây hiện tại theo múi giờ cửa hàng thuộc khoảng nào?"}
Lý do mô hình này dễ đúng: nó không động tới lịch thật. "Giây trong ngày" và "thứ mấy" là hai số nguyên; mọi phép so sánh là so số. Lịch thật (ngày, múi giờ, giờ mùa hè) chỉ xuất hiện ở một chỗ: đổi "bây giờ" thành (thứ, giây trong ngày) theo múi giờ của cửa hàng. Gom phần khó vào một hàm thì chỉ cần kiểm kỹ một hàm.
Hàm đó viết bằng Intl.DateTimeFormat với timeZone là đủ: formatToParts trả về thứ, giờ, phút, giây giờ treo tường ở múi đó, đã tính giờ mùa hè.
#Viết lại
Tách, gộp, phần bù giữ nguyên ý tưởng:
src/lib/weeklyHours.ts
/** Tách khoảng qua đêm thành phần trước nửa đêm và phần sau nửa đêm của ngày hôm sau. */
export function splitOvernight(ranges: WeeklyRange[]): WeeklyRange[] {
return ranges.flatMap((r) => {
if (r.fromSec < r.toSec) return [r];
return [
{ ...r, toSec: SECONDS_IN_DAY },
// toSec = 0 nghĩa là "tới đúng nửa đêm": phần hôm sau rỗng, bỏ đi
...(r.toSec > 0
? [{ ...r, id: `${r.id}_overnight`, weekdays: r.weekdays.map((d) => (d + 1) % 7), fromSec: 0 }]
: []),
];
});
}
/** Các khoảng của một ngày, đã sắp xếp và gộp chỗ chồng nhau. */
export function bracketsForDay(weekday: number, ranges: WeeklyRange[]): Bracket[] {
const sorted = splitOvernight(ranges)
.filter((r) => r.weekdays.includes(weekday))
.sort((a, b) => a.fromSec - b.fromSec);
const merged: Bracket[] = [];
for (const r of sorted) {
const last = merged[merged.length - 1];
if (last && last.to >= r.fromSec) last.to = Math.max(last.to, r.toSec);
else merged.push({ from: r.fromSec, to: r.toSec });
}
return merged;
}
/** Phần bù trong [0, 24h): từ danh sách giờ mở ra danh sách giờ đóng và ngược lại. */
export function complement(brackets: Bracket[]): Bracket[] {
const result: Bracket[] = [];
let cursor = 0;
for (const b of [...brackets].sort((x, y) => x.from - y.from)) {
if (cursor < b.from) result.push({ from: cursor, to: b.from });
// max: an toàn kể cả khi đầu vào chưa được gộp (bản gốc gán thẳng cursor = b.to)
cursor = Math.max(cursor, b.to);
}
if (cursor < SECONDS_IN_DAY) result.push({ from: cursor, to: SECONDS_IN_DAY });
return result;
}
/** Khoảng nửa mở [from, to): đúng 22:00:00 là đã đóng cửa nếu khoảng kết thúc lúc 22:00. */
export function isInBrackets(secondOfDay: number, brackets: Bracket[]) {
return brackets.some((b) => secondOfDay >= b.from && secondOfDay < b.to);
}
/** Cách so sánh của bản gốc: isAfter(from) && isBefore(to), hở cả hai đầu. */
export function fbStyleIsInBrackets(secondOfDay: number, brackets: Bracket[]) {
return brackets.some((b) => secondOfDay > b.from && secondOfDay < b.to);
}
ts
Đọc "bây giờ" theo múi giờ cửa hàng. getOpenStatus (trong file đầy đủ) dùng kết quả này để tìm mốc đổi trạng thái tiếp theo.
src/lib/weeklyHours.ts
/** Thứ và giây trong ngày theo giờ treo tường của múi giờ cửa hàng. */
export function zonedNow(instant: Date, timeZone: string): ZonedNow {
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-US', {
timeZone,
weekday: 'short',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
})
.formatToParts(instant)
.map((p) => [p.type, p.value]),
);
return {
weekday: WEEKDAYS.indexOf(parts.weekday),
secondOfDay: Number(parts.hour) * 3600 + Number(parts.minute) * 60 + Number(parts.second),
date: `${parts.year}-${parts.month}-${parts.day}`,
};
}
ts
Xem toàn bộ src/lib/weeklyHours.ts
src/lib/weeklyHours.ts
/**
* Giờ mở cửa theo tuần (ý tưởng từ PagesTimeRangeEditorUtilities của Facebook Pages).
*
* - Mỗi khoảng: tập ngày trong tuần + giờ bắt đầu/kết thúc tính bằng giây kể từ 0:00.
* - Khoảng qua đêm (22:00 → 02:00) được tách làm hai: [22:00, 24:00) của ngày đã chọn
* và [0:00, 02:00) của NGÀY HÔM SAU, tức (weekday + 1) % 7.
* - Theo từng ngày: lọc, sắp theo giờ bắt đầu, gộp các khoảng chồng nhau.
* - Phần bù trong [0, 24h) cho ra "giờ đóng cửa" (hoặc ngược lại, tuỳ dữ liệu là giờ mở hay giờ vắng).
* - Giờ hiện tại được đọc theo MÚI GIỜ CỦA CỬA HÀNG (Intl), không theo máy người xem.
*/
export const SECONDS_IN_DAY = 86_400;
export interface WeeklyRange {
id: string;
/** 0 = Chủ nhật … 6 = thứ Bảy */
weekdays: number[];
fromSec: number;
toSec: number;
}
export interface Bracket {
from: number;
to: number;
}
/** Tách khoảng qua đêm thành phần trước nửa đêm và phần sau nửa đêm của ngày hôm sau. */
export function splitOvernight(ranges: WeeklyRange[]): WeeklyRange[] {
return ranges.flatMap((r) => {
if (r.fromSec < r.toSec) return [r];
return [
{ ...r, toSec: SECONDS_IN_DAY },
// toSec = 0 nghĩa là "tới đúng nửa đêm": phần hôm sau rỗng, bỏ đi
...(r.toSec > 0
? [{ ...r, id: `${r.id}_overnight`, weekdays: r.weekdays.map((d) => (d + 1) % 7), fromSec: 0 }]
: []),
];
});
}
/** Các khoảng của một ngày, đã sắp xếp và gộp chỗ chồng nhau. */
export function bracketsForDay(weekday: number, ranges: WeeklyRange[]): Bracket[] {
const sorted = splitOvernight(ranges)
.filter((r) => r.weekdays.includes(weekday))
.sort((a, b) => a.fromSec - b.fromSec);
const merged: Bracket[] = [];
for (const r of sorted) {
const last = merged[merged.length - 1];
if (last && last.to >= r.fromSec) last.to = Math.max(last.to, r.toSec);
else merged.push({ from: r.fromSec, to: r.toSec });
}
return merged;
}
/** Phần bù trong [0, 24h): từ danh sách giờ mở ra danh sách giờ đóng và ngược lại. */
export function complement(brackets: Bracket[]): Bracket[] {
const result: Bracket[] = [];
let cursor = 0;
for (const b of [...brackets].sort((x, y) => x.from - y.from)) {
if (cursor < b.from) result.push({ from: cursor, to: b.from });
// max: an toàn kể cả khi đầu vào chưa được gộp (bản gốc gán thẳng cursor = b.to)
cursor = Math.max(cursor, b.to);
}
if (cursor < SECONDS_IN_DAY) result.push({ from: cursor, to: SECONDS_IN_DAY });
return result;
}
/** Khoảng nửa mở [from, to): đúng 22:00:00 là đã đóng cửa nếu khoảng kết thúc lúc 22:00. */
export function isInBrackets(secondOfDay: number, brackets: Bracket[]) {
return brackets.some((b) => secondOfDay >= b.from && secondOfDay < b.to);
}
/** Cách so sánh của bản gốc: isAfter(from) && isBefore(to), hở cả hai đầu. */
export function fbStyleIsInBrackets(secondOfDay: number, brackets: Bracket[]) {
return brackets.some((b) => secondOfDay > b.from && secondOfDay < b.to);
}
export interface ZonedNow {
weekday: number;
secondOfDay: number;
/** "YYYY-MM-DD" theo múi giờ cửa hàng */
date: string;
}
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
/** Thứ và giây trong ngày theo giờ treo tường của múi giờ cửa hàng. */
export function zonedNow(instant: Date, timeZone: string): ZonedNow {
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-US', {
timeZone,
weekday: 'short',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
})
.formatToParts(instant)
.map((p) => [p.type, p.value]),
);
return {
weekday: WEEKDAYS.indexOf(parts.weekday),
secondOfDay: Number(parts.hour) * 3600 + Number(parts.minute) * 60 + Number(parts.second),
date: `${parts.year}-${parts.month}-${parts.day}`,
};
}
export interface OpenStatus {
open: boolean;
/** lần đổi trạng thái tiếp theo, tính bằng số ngày tới và giây trong ngày đó */
next: { inDays: number; weekday: number; secondOfDay: number } | null;
}
/** Đang mở hay đóng, và khi nào đổi. `openRanges` là các khoảng giờ MỞ cửa. */
export function getOpenStatus(now: ZonedNow, openRanges: WeeklyRange[]): OpenStatus {
const today = bracketsForDay(now.weekday, openRanges);
const open = isInBrackets(now.secondOfDay, today);
// duyệt tối đa 8 ngày (hôm nay + 7) để tìm mốc đổi trạng thái đầu tiên
for (let inDays = 0; inDays <= 7; inDays++) {
const weekday = (now.weekday + inDays) % 7;
const brackets = bracketsForDay(weekday, openRanges);
const edges = brackets
.flatMap((b) => [b.from, b.to])
// 24:00 của hôm nay chỉ là mốc đổi nếu ngày mai không mở tiếp từ 0:00
.filter((s) => s < SECONDS_IN_DAY || !isInBrackets(0, bracketsForDay((weekday + 1) % 7, openRanges)))
.filter((s) => s > 0 || !isInBrackets(SECONDS_IN_DAY - 1, bracketsForDay((weekday + 6) % 7, openRanges)))
.filter((s) => inDays > 0 || s > now.secondOfDay)
.sort((a, b) => a - b);
if (edges.length) {
const s = edges[0];
return s >= SECONDS_IN_DAY
? { open, next: { inDays: inDays + 1, weekday: (weekday + 1) % 7, secondOfDay: 0 } }
: { open, next: { inDays, weekday, secondOfDay: s } };
}
}
return { open, next: null };
}
/** "hôm nay" / "ngày mai" / tên thứ: so theo SỐ NGÀY TỚI, không theo getDayOfYear() + 1. */
export function dayLabel(inDays: number, weekday: number) {
if (inDays === 0) return 'hôm nay';
if (inDays === 1) return 'ngày mai';
return ['Chủ nhật', 'thứ Hai', 'thứ Ba', 'thứ Tư', 'thứ Năm', 'thứ Sáu', 'thứ Bảy'][weekday];
}
/** Nhãn kiểu bản gốc: so số thứ tự ngày trong năm, nên 31/12 → 1/1 không ra "ngày mai". */
export function fbStyleDayLabel(todayDayOfYear: number, targetDayOfYear: number) {
if (todayDayOfYear === targetDayOfYear) return 'hôm nay';
if (todayDayOfYear + 1 === targetDayOfYear) return 'ngày mai';
return '(tên thứ)';
}
export function formatSecond(second: number) {
const h = Math.floor(second / 3600);
const m = Math.floor((second % 3600) / 60);
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
export function parseHHMM(value: string) {
const [h, m] = value.split(':').map(Number);
return h * 3600 + m * 60;
}
/**
* Bản gốc đổi "giây trong ngày" ra thời điểm bằng startOfDay().addSeconds(s). Ngày đổi giờ
* mùa hè chỉ có 23 giờ, nên 02:30 cộng từ nửa đêm thành 03:30 giờ treo tường.
* Hàm này tái hiện phép tính đó bằng Intl để demo cho thấy độ lệch.
*/
export function wallClockAfterAddingSeconds(midnightUtc: Date, seconds: number, timeZone: string) {
const at = new Date(midnightUtc.getTime() + seconds * 1000);
return new Intl.DateTimeFormat('vi-VN', { timeZone, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(at);
}
/** Mốc UTC của 0:00 giờ treo tường ngày `date` ở múi `timeZone` (dò theo offset, đủ cho demo). */
export function zonedMidnight(date: string, timeZone: string) {
const [y, m, d] = date.split('-').map(Number);
let guess = Date.UTC(y, m - 1, d);
for (let i = 0; i < 3; i++) {
const z = zonedNow(new Date(guess), timeZone);
const diffDays = (Date.UTC(y, m - 1, d) - Date.parse(`${z.date}T00:00:00Z`)) / 86_400_000;
guess += diffDays * 86_400_000 - z.secondOfDay * 1000;
}
return new Date(guess);
}
ts
Khác bản gốc:
- Khoảng nửa mở
[from, to): mở lúc 07:00:00, đóng lúc 22:00:00. - Không cộng giây từ nửa đêm. So sánh trực tiếp "giây trong ngày" lấy từ
Intltheo múi giờ cửa hàng, nên ngày đổi giờ không lệch. - Nhãn "hôm nay/ngày mai" đếm số ngày tới (
inDays), không so số thứ tự ngày trong năm. - Thêm
getOpenStatustrả luôn mốc đổi trạng thái kế tiếp, xử lý khoảng chạy qua nửa đêm (không báo "đóng lúc 24:00" khi hôm sau mở tiếp từ 0:00). - Khoảng qua đêm kết thúc đúng 0:00 (
to = 0) không sinh ra khoảng rỗng ở ngày hôm sau.
#Đi qua code
splitOvernightdùngflatMap: một khoảng thường cho ra một phần tử, khoảng qua đêm cho ra hai.bracketsForDaygiữ nguyên thuật toán gộp. Dấu>=làm hai khoảng chạm nhau (10:00–14:00 và 14:00–18:00) gộp thành một, đúng ý người dùng.complementdùngMath.maxkhi dời cursor.zonedNowgọiformatToPartsvớihourCycle: 'h23'để giờ luôn từ 00 tới 23 (có engine trả "24" cho nửa đêm nếu dùnghour12: false).getOpenStatusduyệt hôm nay và 7 ngày sau, gom mọi mép khoảng (from,to), bỏ những mép không làm đổi trạng thái (24:00 nối sang 0:00 hôm sau), lấy mép đầu tiên sau "bây giờ".
#Tại sao không làm cách khác?
Lưu Date bắt đầu/kết thúc cho mỗi ngày trong tuần? Lịch "hằng tuần" không gắn với ngày cụ thể. Lưu ngày giờ thật buộc phải chọn một tuần mẫu, rồi đổi múi giờ hoặc giờ mùa hè là lệch.
Lưu khoảng qua đêm thành to = 26:00? Gọn khi nhập, nhưng mọi chỗ dùng phải nhớ cắt phần vượt 24:00 sang hôm sau. Tách ngay khi đọc thì phần còn lại chỉ làm việc với [0, 24h).
Tính "bây giờ" bằng new Date().getHours()? Đó là giờ của máy người xem. Khách ở Hà Nội xem quán ở New York sẽ thấy giờ mở cửa lệch 11–12 tiếng.
#Edge case và cạm bẫy
- Khoảng
from = to: bản gốc coi là qua đêm, cho ra[from, 24h)hôm nay cộng[0, from)hôm sau, tức đúng 24 giờ bắt đầu từfrom. Bản viết lại giữ hành vi đó; riêng0 → 0ra một khoảng[0, 24h)trong ngày. - Khoảng của thứ Bảy kéo sang Chủ nhật:
(6 + 1) % 7 = 0, đã được tính. - Hai dòng chồng nhau: 10:00–14:00 và 13:00–22:00 hiển thị thành 10:00–22:00 trên timeline. Nếu muốn báo lỗi "các khoảng chồng nhau" cho người nhập thì kiểm ở bước nhập, đừng dựa vào bước gộp.
- Múi giờ không hợp lệ:
Intl.DateTimeFormatnémRangeError. Bản gốc báo "Bạn phải cung cấp múi giờ" ở bước validate. - Ngày đổi giờ lùi (mùa thu): 01:00–02:00 xảy ra hai lần. Mô hình "giây trong ngày" coi hai lần đó như nhau, thường là điều người dùng muốn.
#Dùng trong dự án
const hours: WeeklyRange[] = [
{ id: 'weekday', weekdays: [1, 2, 3, 4], fromSec: parseHHMM('18:00'), toSec: parseHHMM('23:00') },
{ id: 'weekend', weekdays: [5, 6], fromSec: parseHHMM('18:00'), toSec: parseHHMM('02:00') },
];
const status = getOpenStatus(zonedNow(new Date(), 'America/New_York'), hours);
const text = status.open
? `Đang mở, đóng lúc ${formatSecond(status.next!.secondOfDay)} ${dayLabel(status.next!.inDays, status.next!.weekday)}`
: status.next
? `Đang đóng, mở lại lúc ${formatSecond(status.next.secondOfDay)} ${dayLabel(status.next.inDays, status.next.weekday)}`
: 'Đóng cửa';
ts
Server cũng nên dùng đúng các hàm này (chạy được trên Node) để trang tĩnh và API trả cùng một kết quả.
#Kiểm thử
Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it } from 'vitest';
import { bracketsForDay, complement, getOpenStatus, isInBrackets, fbStyleIsInBrackets, parseHHMM as t, zonedNow, type WeeklyRange } from './weeklyHours';
const bar: WeeklyRange[] = [
{ id: 'wk', weekdays: [1, 2, 3, 4], fromSec: t('18:00'), toSec: t('23:00') },
{ id: 'we', weekdays: [5, 6], fromSec: t('18:00'), toSec: t('02:00') },
];
describe('weeklyHours', () => {
it('khoảng qua đêm kéo sang hôm sau', () => {
expect(bracketsForDay(0, bar)).toEqual([{ from: 0, to: t('02:00') }]); // rạng sáng Chủ nhật
expect(bracketsForDay(6, bar)).toEqual([{ from: 0, to: t('02:00') }, { from: t('18:00'), to: 86400 }]);
});
it('gộp khoảng chồng và khoảng chạm nhau', () => {
const r: WeeklyRange[] = [
{ id: 'a', weekdays: [1], fromSec: t('10:00'), toSec: t('14:00') },
{ id: 'b', weekdays: [1], fromSec: t('13:00'), toSec: t('18:00') },
{ id: 'c', weekdays: [1], fromSec: t('18:00'), toSec: t('20:00') },
];
expect(bracketsForDay(1, r)).toEqual([{ from: t('10:00'), to: t('20:00') }]);
expect(complement(bracketsForDay(1, r))).toEqual([{ from: 0, to: t('10:00') }, { from: t('20:00'), to: 86400 }]);
});
it('biên nửa mở, khác bản gốc', () => {
const b = [{ from: t('07:00'), to: t('22:00') }];
expect(isInBrackets(t('07:00'), b)).toBe(true);
expect(fbStyleIsInBrackets(t('07:00'), b)).toBe(false);
expect(isInBrackets(t('22:00'), b)).toBe(false);
});
it('tối thứ Sáu 23:30: đang mở, đóng 02:00 ngày mai', () => {
const s = getOpenStatus({ weekday: 5, secondOfDay: t('23:30'), date: '' }, bar);
expect(s).toEqual({ open: true, next: { inDays: 1, weekday: 6, secondOfDay: t('02:00') } });
});
it('giờ theo múi của cửa hàng, không theo máy', () => {
const now = zonedNow(new Date('2026-12-31T16:30:00Z'), 'Asia/Ho_Chi_Minh');
expect(now).toEqual({ weekday: 4, secondOfDay: t('23:30'), date: '2026-12-31' });
});
});
ts
#So với thư viện và chuẩn web
Intl.DateTimeFormat+timeZoneđã đủ để đọc giờ treo tường ở múi bất kỳ, có ở mọi trình duyệt hiện đại và Node. Không cần nạp dữ liệu múi giờ riêng như thời moment-timezone.- Temporal có
Temporal.ZonedDateTimevàTemporal.PlainTime, đúng hai khái niệm bài này dùng ("thời điểm ở một múi giờ" và "giờ trong ngày không gắn ngày"). Khi Temporal có sẵn ở các trình duyệt bạn hỗ trợ,zonedNowgọn còn một dòng. - schema.org
OpeningHoursSpecificationdùng đúng mô hình "ngày trong tuần + opens + closes". Lưu theo mô hình này thì xuất ra dữ liệu có cấu trúc cho công cụ tìm kiếm rất dễ.
Bài liên quan: Học từ web lớn #49: xử lý giờ mùa hè không cần thư viện, Học từ web lớn #26: parse ngày giờ gõ tay khoan dung.
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
- Mở đầu: cách đọc code minify của Facebook
- #1 Dùng chung IntersectionObserver + infinite scroll
- #2 useViewportDuration: đo thời gian thực sự được xem
- #3 Preload code + data khi hover
- #4 Toast manager với external store
- #5 Network status + heartbeat backoff
- #6 ErrorGuard: bắt lỗi theo ngữ cảnh
- #7 Banzai: gom log theo lô + sendBeacon
- #8 Focus trap + Esc theo layer
Phần 2
- #9 Mẹo calc(… * 9999): container query, line-clamp, mũi tên carousel bằng CSS
- #10 Thanh cuộn tuỳ biến bằng CSS 3D
- #11 Modal cuộn bằng window, giữ vị trí trang nền
- #12 Popover, tooltip group và hovercard
- #13 Primitive Pressable + focus-visible
- #14 Tab "Xem thêm" + sticky header bằng IntersectionObserver
- #15 Avatar khoét mask, gradient Messenger, skeleton steps()
- #16 Khung chat không giật
- #17 Thuật toán chọn bố cục collage ảnh
- #18 Keep-alive tab trong SPA
- #19 Tự đo Visual Completion & interaction tracing
- #20 Router SPA bền bỉ & tải chunk có retry
Phần 3
- #21 Smart Compose: gợi ý chữ mờ khi gõ
- #22 MQTT ngay trong trình duyệt, tự chuyển sang long-poll
- #23 Đo thời gian dùng bằng bitmap 64 giây
- #24 Bảng reaction nhấn giữ rồi trượt
- #25 Tìm kiếm gấp dấu, phiên âm và highlight đầu từ
- #26 Ring buffer biết trace nào đã mất log
- #27 Engine animation JS: một vòng rAF và solver cubic-bezier
- #28 Bootloader: bitmap tài nguyên đã tải, chỉ reload khi cần
- #29 Nhận biết click do screen reader tạo ra
- #30 Tải trước feed theo chất lượng mạng, đoán hạng thiết bị
- #31 Hàng đợi gửi tin chat giữ đúng thứ tự
- #32 Gộp lời gọi API trong cùng một tick
- #33 Resize ảnh trong pool Web Worker
- #34 Crop ảnh theo nhiều khuôn mặt
- #35 Kéo-thả sắp xếp và vùng thả file không nhấp nháy
- #36 Tiện ích UI: fade-out, sticky event, hàm huỷ được
- #37 Hạ tầng nhỏ: cookie, hash lỗi, prefetch khi chuột dừng
Phần 4–6
- #38 Đồng bộ tin nhắn: phát hiện lỗ seqID và fetch bù theo tầng
- #39 OTID: ID tin nhắn kiểu Snowflake sinh ở client
- #40 QPL: lấy mẫu từ lúc bắt đầu, đo RTT gửi tin đầu-cuối
- #41 Bộ điều phối request bảy chế độ gộp và chống trùng
- #42 Thread list theo mốc thời gian, cookie presence có version
- #43 Composer dạng plugin: reducer, dirty checker, chuỗi kiểm tra
- #44 Thanh toán bất đồng bộ: subscribe trước, poll dự phòng
- #45 Validate số thẻ: sửa lỗi gõ, dải BIN, Luhn
- #46 Lịch chọn khoảng ngày: bo góc dải chọn, phím đủ bộ, RTL
- #47 Giờ mở cửa theo tuần: qua đêm, gộp khoảng, phần bù (bài này)
- #48 Tiến độ đăng realtime: giữ event chờ snapshot, watchdog
- #49 Tiền tệ theo đơn vị nhỏ nhất và ô nhập tiền
- #50 Sửa hàng loạt Empty/Uniform/Mixed, seen/read bằng 2 bit
- #51 Phát hiện script lạ hook vào hàm native
- #52 localStorage tự dọn khi đầy quota
- #53 Cache tuyến do server phân giải, chờ sẵn promise
- #54 Lưới phím mũi tên 2D và focus không làm cuộn trang
- #55 Tách emoji bằng máy trạng thái
- #56 Đọc trước ghi sau trong một frame, con tự biết vị trí
- #57 Hàng đợi lệnh cho video và stream sắp lại chunk
Phần 7–10
- #58 Ước lượng băng thông EWMA: loại outlier, nhớ qua phiên
- #59 ABR theo độ tự tin phát và cắt bậc bitrate theo MOS
- #60 Prefetch segment dự đoán từ manifest, parse hộp sidx
- #61 Đọc metadata MP4/MOV ngay trên client
- #62 Upload tiếp tục được: offset, hash dedupe, phát hiện treo
- #63 Fenwick tree: chọn ngẫu nhiên có trọng số và offset dòng
- #64 Vạch trục số và trục thời gian "đẹp"
- #65 Damerau-Levenshtein theo dải chéo và tự sửa tên gõ sai
- #66 PRNG có seed, shuffle tái lập và lấy mẫu trace
- #67 Cặp min/max-heap tái dùng vị trí trong danh sách ảo
- #68 Memo nhiều đối số bằng Map/WeakMap và interning object
- #69 Thống kê cửa sổ trượt và đại số khoảng thời gian
- #70 Int64 từ hai số 32-bit, decode UTF, offset theo code point
- #71 Engine múi giờ IANA trên client, phát hiện lệch giờ
- #72 Tự cài semver range
- #73 Auto-link URL, cắt chữ an toàn, giới hạn LaTeX
- #74 Parser query kiểu PHP chống prototype pollution
- #75 Popover tự lật theo phần tràn nhỏ nhất
- #76 Tiền tố for(;😉 chống JSON hijacking, đọc stream
- #77 Chống lộ Referer, linkshim, xoá token trên URL
- #78 setImmediate bằng postMessage, gom delta chuột theo frame
- #79 Phát hiện crash qua heartbeat, dò proxy cache hỏng
- #80 DPR vào cookie cho ảnh @2x, che khoá trong log
- #81 Flux waitFor tự viết và proxy có đệm
- #82 Crop đúng tỉ lệ, xoay sticker bằng ma trận
Phần 11–15
- #83 Chia subdomain giữa các tab bằng lease, long-poll theo bảng trạng thái
- #84 MQTT QoS1: giữ publish chờ ack, gửi lại khi kết nối lại
- #85 Cache Promise gộp request, cache lỗi ngắn, đua dữ liệu nhúng sẵn
- #86 Lịch retry: backoff có trần, Fibonacci, báo lỗi CDN mỗi N ngày
- #87 Giữ nguyên tham chiếu nhánh dữ liệu không đổi
- #88 Store optimistic: copy-on-write, rollback rồi áp lại
- #89 Chỉ mục subscription theo record, hàng đợi task theo chiều sâu
- #90 Phân trang connection: cursor khớp đầu, edge không trùng
- #91 Profiler kiểu middleware và memo selector có lấy mẫu kiểm tra
- #92 Đọc EBML/WebM Cues để tìm byte range của segment
- #93 Stream HTTP Range tạm dừng được, tải tiếp đúng byte
- #94 Bảng khoảng thời gian gắn nhãn và nhảy qua lỗ buffer
- #95 Gom chunk cho SourceBuffer, đồng bộ video bằng playbackRate
- #96 Gom tin nhắn thành cụm, vạch ngày và avatar đã xem
- #97 Bao nhiêu tab chat vừa màn hình, chuỗi luật mở tab
- #98 Phím tắt Markdown trong editor, Backspace để hoàn tác
- #99 Offset theo code point hay UTF-16: bốn bug cùng một gốc
- #100 Typeahead không nhảy: cache tiền tố, chặn reflow, khoá hover
- #101 Hit-test trên canvas bằng phát lại path, nét đứt và cung Bézier
- #102 Vòng tròn theo mét trên bản đồ Mercator
- #103 Dò tính năng trình duyệt: ảnh EXIF 2×1, getter bẫy, scrollbar
- #104 Bug định dạng số và kiểm tra dữ liệu theo locale
- #105 Dispose cho đúng: idle callback, splice(-1), xoá nhầm key
Code + demo: examples/facebook-patterns · Mở trên StackBlitz

