hbotutil
Version:
Delivery/pickup date helpers for Germany (weekends + union of major state holidays)
325 lines (278 loc) • 12.7 kB
JavaScript
/*! 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
});
});