@empellio/business-hours
Version:
A lightweight and accurate TypeScript library for handling business opening hours. Supports multiple daily time slots, holidays, exceptions, overnight openings, and timezone-aware calculations.
302 lines (294 loc) • 9.61 kB
JavaScript
'use strict';
var luxon = require('luxon');
// src/core/compute.ts
// src/helpers/parseHHMM.ts
function isHHMM(value) {
return /^\d{2}:\d{2}$/.test(value);
}
// src/helpers/mergeSlots.ts
function validateAndSortDay(slots, strict) {
const valid = [];
for (const s of slots) {
if (!isHHMM(s.open) || !isHHMM(s.close)) {
if (strict) throw new Error(`Invalid HH:MM in slot ${s.open}-${s.close}`);
else continue;
}
valid.push({ open: s.open, close: s.close });
}
valid.sort((a, b) => a.open < b.open ? -1 : a.open > b.open ? 1 : 0);
const merged = [];
for (const s of valid) {
const last = merged[merged.length - 1];
if (!last) {
merged.push(s);
continue;
}
if (s.open <= last.close) {
if (s.close > last.close) last.close = s.close;
} else {
merged.push({ ...s });
}
}
return merged;
}
// src/core/normalize.ts
var WEEK_KEYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
function normalizeConfig(input) {
const strict = Boolean(input.strictValidation);
const locale = input.locale ?? "en-US";
const firstDayOfWeek = input.firstDayOfWeek ?? "mon";
const holidays = input.holidays ?? [];
const exceptions = input.exceptions ?? [];
const week = { ...input.week };
for (const k of WEEK_KEYS) {
if (!(k in week)) {
if (strict) throw new Error(`Missing weekday: ${k}`);
week[k] = "closed";
}
const v = week[k];
if (v !== "closed") {
week[k] = validateAndSortDay(v, strict);
}
}
return {
timezone: input.timezone,
week,
holidays,
exceptions,
locale,
firstDayOfWeek,
strictValidation: strict
};
}
function dayKeyFromLuxon(dt) {
const map = {
1: "mon",
2: "tue",
3: "wed",
4: "thu",
5: "fri",
6: "sat",
7: "sun"
};
return map[dt.weekday];
}
function toJSON(config) {
return JSON.parse(JSON.stringify(config));
}
function resolveDaySpecForDate(dateISO, cfg) {
const ex = cfg.exceptions?.find((e) => e.date === dateISO);
if (ex) return ex.closed ? "closed" : ex.slots ?? "closed";
const hol = cfg.holidays?.find((h) => h.date === dateISO);
if (hol) return hol.closed ? "closed" : hol.slots ?? "closed";
const dt = luxon.DateTime.fromISO(dateISO, { zone: cfg.timezone });
const key = dayKeyFromLuxon(dt);
return cfg.week[key];
}
function dtFrom(date, zone) {
if (date === void 0) return luxon.DateTime.now().setZone(zone);
if (date instanceof Date) return luxon.DateTime.fromJSDate(date).setZone(zone);
const parsed = luxon.DateTime.fromISO(date, { setZone: true });
return parsed.isValid ? parsed.setZone(zone) : luxon.DateTime.invalid("Invalid date");
}
// src/core/compute.ts
function createBusinessHours(config) {
const cfg = normalizeConfig(config);
const cache = /* @__PURE__ */ new Map();
const MAX_CACHE_DAYS = 14;
function slotsOn(date) {
const dt = dtFrom(date, cfg.timezone);
const startOfDay = dt.startOf("day");
const dateISO = startOfDay.toISODate();
const cached = cache.get(dateISO);
if (cached) return cached;
const spec = resolveDaySpecForDate(dateISO, cfg);
const ranges = [];
if (spec !== "closed") {
for (const s of spec) {
if (s.open <= s.close) {
ranges.push(rangeFor(startOfDay, s));
} else {
ranges.push(rangeFor(startOfDay, { open: s.open, close: "23:59" }));
}
}
}
const prevDay = startOfDay.minus({ days: 1 }).startOf("day");
const prevISO = prevDay.toISODate();
const prevSpec = resolveDaySpecForDate(prevISO, cfg);
if (prevSpec !== "closed") {
for (const s of prevSpec) {
if (s.open > s.close) {
ranges.push(rangeFor(startOfDay, { open: "00:00", close: s.close }));
}
}
}
const result = ranges.map((r) => ({ open: r.open.toJSDate(), close: r.close.toJSDate() })).sort((a, b) => a.open.getTime() - b.open.getTime());
cache.set(dateISO, result);
if (cache.size > MAX_CACHE_DAYS) {
const firstKey = cache.keys().next().value;
if (firstKey) cache.delete(firstKey);
}
return result;
}
function currentSlot(at) {
const now = dtFrom(at, cfg.timezone);
const todays = slotsOn(now.toJSDate());
for (const s of todays) {
const o = luxon.DateTime.fromJSDate(s.open);
const c = luxon.DateTime.fromJSDate(s.close);
if (luxon.Interval.fromDateTimes(o, c).contains(now)) return s;
}
return null;
}
function isOpenAt(date) {
return currentSlot(date) !== null;
}
function isOpenNow() {
return isOpenAt(dtFrom(void 0, cfg.timezone).toJSDate());
}
function nextOpen(from) {
const start = dtFrom(from, cfg.timezone);
const todaySlots = slotsOn(start.toJSDate());
for (const slot of todaySlots) {
const o = luxon.DateTime.fromJSDate(slot.open);
const c = luxon.DateTime.fromJSDate(slot.close);
if (c <= start) continue;
if (start < o) return { start: o.toJSDate(), end: c.toJSDate() };
if (luxon.Interval.fromDateTimes(o, c).contains(start)) return { start: start.toJSDate(), end: c.toJSDate() };
}
for (let i = 1; i <= 14; i++) {
const day = start.plus({ days: i }).startOf("day");
const slots = slotsOn(day.toJSDate());
if (slots.length > 0) {
return { start: slots[0].open, end: slots[0].close };
}
}
return null;
}
function nextClose(from) {
const start = dtFrom(from, cfg.timezone);
const slot = currentSlot(start.toJSDate());
if (slot) return { at: slot.close };
const upcoming = nextOpen(start.toJSDate());
if (!upcoming) return null;
return { at: upcoming.end };
}
function timeUntilClose(at) {
const start = dtFrom(at, cfg.timezone);
const slot = currentSlot(start.toJSDate());
if (!slot) return null;
const diff = luxon.DateTime.fromJSDate(slot.close).diff(start).as("milliseconds");
if (diff <= 0) return null;
return { ms: diff, minutes: Math.ceil(diff / 6e4) };
}
function timeUntilOpen(at) {
const start = dtFrom(at, cfg.timezone);
if (currentSlot(start.toJSDate())) return { ms: 0, minutes: 0 };
const upcoming = nextOpen(start.toJSDate());
if (!upcoming) return null;
const diff = luxon.DateTime.fromJSDate(upcoming.start).diff(start).as("milliseconds");
return { ms: diff, minutes: Math.ceil(diff / 6e4) };
}
function todaysSlots(at) {
const dt = dtFrom(at, cfg.timezone);
return slotsOn(dt.toJSDate());
}
function weeklySummary(options) {
const lines = [];
const start = luxon.DateTime.now().setZone(cfg.timezone).startOf("week");
for (let i = 0; i < 7; i++) {
const day = start.plus({ days: i });
const slots = slotsOn(day.toJSDate());
if (slots.length === 0) {
lines.push(`${day.toFormat("EEE")} closed`);
} else {
const parts = slots.map((s) => {
const o = luxon.DateTime.fromJSDate(s.open).setZone(cfg.timezone);
const c = luxon.DateTime.fromJSDate(s.close).setZone(cfg.timezone);
return `${o.toFormat("HH:mm")}\u2013${c.toFormat("HH:mm")}`;
});
lines.push(`${day.toFormat("EEE")} ${parts.join(", ")}`);
}
}
return options?.join ? lines.join("\n") : lines;
}
function listUpcomingSlots(daysAhead = 14) {
const now = luxon.DateTime.now().setZone(cfg.timezone).startOf("day");
const out = [];
for (let i = 0; i <= daysAhead; i++) {
const day = now.plus({ days: i });
const dateISO = day.toISODate();
const slots = slotsOn(day.toJSDate());
out.push({ date: dateISO, slots });
}
return out;
}
function withOverrides(overrides) {
return createBusinessHours({ ...cfg, ...overrides });
}
function toJSONLocal() {
return {
timezone: cfg.timezone,
week: cfg.week,
holidays: cfg.holidays,
exceptions: cfg.exceptions,
locale: cfg.locale,
firstDayOfWeek: cfg.firstDayOfWeek,
strictValidation: cfg.strictValidation
};
}
return {
isOpenNow,
isOpenAt,
nextOpen,
nextClose,
currentSlot,
timeUntilClose,
timeUntilOpen,
todaysSlots,
slotsOn,
weeklySummary,
listUpcomingSlots,
withOverrides,
toJSON: toJSONLocal
};
}
function rangeFor(dayStart, slot) {
const [oh, om] = slot.open.split(":").map(Number);
const [ch, cm] = slot.close.split(":").map(Number);
const open = dayStart.set({ hour: oh, minute: om, second: 0, millisecond: 0 });
const close = dayStart.set({ hour: ch, minute: cm, second: 0, millisecond: 0 });
return { open, close };
}
function formatSlot(slot, options) {
const zone = options?.timezone;
const locale = options?.locale ?? "en-US";
const o = luxon.DateTime.fromJSDate(slot.open).setZone(zone).setLocale(locale);
const c = luxon.DateTime.fromJSDate(slot.close).setZone(zone).setLocale(locale);
return `${o.toFormat("HH:mm")}\u2013${c.toFormat("HH:mm")}`;
}
function formatDayName(weekday, options) {
const locale = options?.locale ?? "en-US";
const map = {
mon: 1,
tue: 2,
wed: 3,
thu: 4,
fri: 5,
sat: 6,
sun: 7
};
const dt = luxon.DateTime.now().set({ weekday: map[weekday] }).setLocale(locale);
return dt.toFormat("EEE");
}
// src/index.ts
function fromJSON(configLike) {
return createBusinessHours(configLike);
}
exports.createBusinessHours = createBusinessHours;
exports.formatDayName = formatDayName;
exports.formatSlot = formatSlot;
exports.fromJSON = fromJSON;
exports.toJSON = toJSON;
//# sourceMappingURL=index.cjs.map
//# sourceMappingURL=index.cjs.map