UNPKG

hbotutil

Version:

Delivery/pickup date helpers for Germany (weekends + union of major state holidays)

325 lines (278 loc) 12.7 kB
/*! NRShip v1 — delivery/pickup date helpers (DE holidays, union of states) */ (function (root, factory) { if (typeof define === 'function' && define.amd) { define([], factory); } else if (typeof module === 'object' && module.exports) { module.exports = factory(); } else { root.NRShip = factory(); } })(typeof self !== 'undefined' ? self : this, function () { 'use strict'; function formatAsIsoDate(date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } function calculateEaster(year) { const century = Math.floor(year / 100); const m = 15 + Math.floor((3 * century + 3) / 4) - Math.floor((8 * century + 13) / 25); const s = 2 - Math.floor((3 * century + 3) / 4); const a = year % 19; const d = (19 * a + m) % 30; const r = Math.floor(d / 29) + (Math.floor(d / 28) - Math.floor(d / 29)) * Math.floor(a / 11); const og = 21 + d - r; const sz = 7 - ((year + Math.floor(year / 4) + s) % 7); const oe = 7 - ((og - sz) % 7); const easterDay = og + oe; return easterDay <= 31 ? new Date(year, 2, easterDay) : new Date(year, 3, easterDay - 31); } function isWeekend(date) { const dayOfWeek = date.getDay(); return dayOfWeek === 0 || dayOfWeek === 6; } function getHolidays(year) { const holidaySet = new Set(); const addFixed = (month, day) => holidaySet.add(formatAsIsoDate(new Date(year, month - 1, day))); const addOffset = (base, offsetDays) => { const d = new Date(base); d.setDate(d.getDate() + offsetDays); holidaySet.add(formatAsIsoDate(d)); }; // Movable (national) const easter = calculateEaster(year); addOffset(easter, -2); // Good Friday addOffset(easter, 1); // Easter Monday addOffset(easter, 39); // Ascension Day addOffset(easter, 50); // Whit Monday // Fixed (national) addFixed(1, 1); // New Year addFixed(5, 1); // Labour Day addFixed(10, 3); // German Unity Day addFixed(12, 25); // Christmas addFixed(12, 26); // 2nd Christmas Day // Union of state holidays (major ones; excludes municipal-only) addFixed(1, 6); // Epiphany (BW, BY, ST) addFixed(3, 8); // Int'l Women's Day (BE, MV) addOffset(easter, 60); // Corpus Christi (many states) addFixed(8, 15); // Assumption (BY parts, SL) addFixed(9, 20); // World Children's Day (TH) addFixed(10, 31); // Reformation Day (several states) addFixed(11, 1); // All Saints (BW, BY, NW, RP, SL) // Buß- und Bettag: Wednesday before Nov 23 (SN) (function addBussUndBettag() { const d = new Date(year, 10, 23); // Nov = 10 while (d.getDay() !== 3) d.setDate(d.getDate() - 1); // 3 = Wednesday holidaySet.add(formatAsIsoDate(d)); })(); return holidaySet; } function buildHolidaySetAround(year) { const union = new Set(); [year - 1, year, year + 1].forEach((y) => { for (const day of getHolidays(y)) union.add(day); }); return union; } function isHoliday(date, holidays) { return holidays.has(formatAsIsoDate(date)); } function sameOrNextWorkingDay(date, holidays) { const d = new Date(date); while (isWeekend(d) || isHoliday(d, holidays)) d.setDate(d.getDate() + 1); return d; } function nextWorkingDay(date, holidays) { const d = new Date(date); d.setDate(d.getDate() + 1); while (isWeekend(d) || isHoliday(d, holidays)) d.setDate(d.getDate() + 1); return d; } function previousWorkingDay(date, holidays) { const d = new Date(date); d.setDate(d.getDate() - 1); while (isWeekend(d) || isHoliday(d, holidays)) d.setDate(d.getDate() - 1); return d; } function addDays(date, days) { const d = new Date(date); d.setDate(d.getDate() + Number(days)); return formatAsIsoDate(d); } function splitHumanName(input) { const out = { salutation: '', firstName: '', middleName: '', lastName: '', suffix: '' }; const name = String(input ?? '').replace(/\s+/g, ' ').trim(); if (!name) return out; const titleSet = new Set([ 'mr', 'mister', 'mrs', 'ms', 'miss', 'mx', 'dr', 'doctor', 'prof', 'herr', 'frau', 'sir', 'madam', 'monsieur', 'mme', 'señor', 'señora', 'srta' ]); const suffixSet = new Set(['jr', 'sr', 'ii', 'iii', 'iv', 'phd', 'md', 'mba', 'esq']); const particles = new Set([ 'von', 'van', 'der', 'den', 'de', 'del', 'della', 'du', 'da', 'dos', 'das', 'la', 'le', 'st', 'st.', 'san', 'santa', 'bin', 'binti', 'al', 'el', 'ibn' ]); const norm = (s) => s.toLowerCase().replace(/\.$/, ''); const peelTitle = (parts) => { const pulled = []; while (parts.length && titleSet.has(norm(parts[0]))) pulled.push(parts.shift()); return [pulled.join(' '), parts]; }; const peelSuffix = (parts) => { const pulled = []; while (parts.length && suffixSet.has(norm(parts[parts.length - 1]))) pulled.unshift(parts.pop()); return [pulled.join(' '), parts]; }; if (name.includes(',')) { let [lastPart, restPart] = name.split(',', 2).map((s) => s.trim()); let rest = restPart.split(' ').filter(Boolean); let suffix = ''; [suffix, rest] = (function (parts) { const [s, p] = peelSuffix(parts); return [s, p]; })(rest); let salutation = ''; [salutation, rest] = peelTitle(rest); out.salutation = salutation; out.suffix = suffix; out.firstName = rest.shift() ?? ''; out.middleName = rest.join(' '); out.lastName = lastPart; return out; } let parts = name.split(' ').filter(Boolean); let salutation = ''; [salutation, parts] = peelTitle(parts); let suffix = ''; [suffix, parts] = (function (parts) { const [s, p] = peelSuffix(parts); return [s, p]; })(parts); if (parts.length === 1) { out.salutation = salutation; out.suffix = suffix; out.firstName = parts[0]; return out; } if (parts.length === 2) { out.salutation = salutation; out.suffix = suffix; out.firstName = parts[0]; out.lastName = parts[1]; return out; } const lastTokens = [parts[parts.length - 1]]; let i = parts.length - 2; while (i >= 1 && particles.has(norm(parts[i]))) { lastTokens.unshift(parts[i]); i--; } const first = parts[0]; const middle = parts.slice(1, i + 1).join(' '); const last = lastTokens.join(' '); out.salutation = salutation; out.suffix = suffix; out.firstName = first; out.middleName = middle; out.lastName = last; return out; } function pickupAnchoredShipmentDates(requestedPickupDate) { const startDate = new Date(requestedPickupDate); const today = new Date(); // Calculate minimum pickup date as 2 normal days from today const minPickupDate = new Date(today); minPickupDate.setDate(today.getDate() + 2); // Always use the later of requested date or minimum pickup date const effectiveStartDate = startDate.getTime() > minPickupDate.getTime() ? startDate : minPickupDate; const holidays = buildHolidaySetAround(effectiveStartDate.getFullYear()); const pickupDateObj = sameOrNextWorkingDay(effectiveStartDate, holidays); const deliveryDateObj = nextWorkingDay(pickupDateObj, holidays); return { pickupDate: formatAsIsoDate(pickupDateObj), deliveryDate: formatAsIsoDate(deliveryDateObj) }; } function deliveryAnchoredShipmentDates(requestedDeliveryDate) { const targetDate = new Date(requestedDeliveryDate); const today = new Date(); // Calculate minimum pickup date as 2 normal days from today const minPickupDate = new Date(today); minPickupDate.setDate(today.getDate() + 2); const holidays = buildHolidaySetAround(targetDate.getFullYear()); const deliveryDateObj = sameOrNextWorkingDay(targetDate, holidays); const pickupDateObj = previousWorkingDay(deliveryDateObj, holidays); // Always ensure pickup date is at least 2 normal days from today const finalPickupDate = pickupDateObj.getTime() > minPickupDate.getTime() ? pickupDateObj : minPickupDate; // Ensure the final pickup date is a working day const finalPickupDateWorking = sameOrNextWorkingDay(finalPickupDate, holidays); const finalDeliveryDate = nextWorkingDay(finalPickupDateWorking, holidays); return { pickupDate: formatAsIsoDate(finalPickupDateWorking), deliveryDate: formatAsIsoDate(finalDeliveryDate) }; } /** * Build { at_iso, at_unix, date } for Customer.io. * - Input can be "YYYY-MM-DD", ISO string, Date, or epoch (s/ms). * - date: YYYY-MM-DD in the given time zone (default Europe/Berlin) * - at_iso: ISO-8601 instant (string) * - at_unix: Unix seconds anchored to 12:00:00Z of that date (stable for segments) */ function formatDateForCustomerIo(input, { timeZone = "Europe/Berlin", prefix } = {}) { const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/; const isDateOnly = typeof input === "string" && DATE_ONLY_RE.test(input.trim()); // Convert non-date-only inputs to a Date instant function toInstant(x) { if (isDateOnly) return null; if (x instanceof Date) return new Date(x.getTime()); if (typeof x === "number") return new Date(x < 1e12 ? x * 1000 : x); const d = new Date(x); if (Number.isNaN(d.getTime())) throw new Error("Invalid date input"); return d; } const instant = toInstant(input); // YYYY-MM-DD in specific IANA TZ function formatYmdInTZ(date, tz) { return new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit", }).format(date); // e.g., 2025-09-02 } // ISO of local midnight for a date-only in TZ (avoids DST bugs) function localMidnightIso(ymd, tz) { const [y, m, d] = ymd.split("-").map(Number); const anchor = new Date(Date.UTC(y, m - 1, d, 12)); const dtf = new Intl.DateTimeFormat("en-US", { timeZone: tz, hour12: false, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", }); const parts = Object.fromEntries( dtf.formatToParts(anchor).filter(p => p.type !== "literal").map(p => [p.type, p.value]) ); const offsetMs = Date.UTC(+parts.year, +parts.month - 1, +parts.day, +parts.hour, +parts.minute, +parts.second) - anchor.getTime(); const utcEpoch = Date.UTC(y, m - 1, d, 0, 0, 0) - offsetMs; return new Date(utcEpoch).toISOString(); } // Noon Z of that calendar date for stable Unix seconds const noonZUnix = (ymd) => Math.floor(Date.parse(ymd + "T12:00:00Z") / 1000); // Build the base object let at_iso, date, at_unix; if (isDateOnly) { date = input.trim(); at_iso = localMidnightIso(date, timeZone); at_unix = noonZUnix(date); } else { at_iso = instant.toISOString(); date = formatYmdInTZ(instant, timeZone); at_unix = noonZUnix(date); } // Optionally prefix keys (e.g., "pickup_at_iso") if (prefix) { return { [`${prefix}_at_iso`]: at_iso, [`${prefix}_at_unix`]: at_unix, [`${prefix}_date`]: date, }; } return { at_iso, at_unix, date }; } return Object.freeze({ addDays, splitHumanName, pickupAnchoredShipmentDates, deliveryAnchoredShipmentDates, formatDateForCustomerIo }); });