472 lines
14 KiB
JavaScript
472 lines
14 KiB
JavaScript
|
|
/* ui-holidays-data.js - Holiday data and date resolution helpers */
|
||
|
|
(function () {
|
||
|
|
"use strict";
|
||
|
|
|
||
|
|
const HEBREW_MONTH_ALIAS_BY_ID = {
|
||
|
|
nisan: ["nisan"],
|
||
|
|
iyar: ["iyar"],
|
||
|
|
sivan: ["sivan"],
|
||
|
|
tammuz: ["tamuz", "tammuz"],
|
||
|
|
av: ["av"],
|
||
|
|
elul: ["elul"],
|
||
|
|
tishrei: ["tishri", "tishrei"],
|
||
|
|
cheshvan: ["heshvan", "cheshvan", "marcheshvan"],
|
||
|
|
kislev: ["kislev"],
|
||
|
|
tevet: ["tevet"],
|
||
|
|
shvat: ["shevat", "shvat"],
|
||
|
|
adar: ["adar", "adar i", "adar 1"],
|
||
|
|
"adar-ii": ["adar ii", "adar 2"]
|
||
|
|
};
|
||
|
|
|
||
|
|
const MONTH_NAME_TO_INDEX = {
|
||
|
|
january: 0,
|
||
|
|
february: 1,
|
||
|
|
march: 2,
|
||
|
|
april: 3,
|
||
|
|
may: 4,
|
||
|
|
june: 5,
|
||
|
|
july: 6,
|
||
|
|
august: 7,
|
||
|
|
september: 8,
|
||
|
|
october: 9,
|
||
|
|
november: 10,
|
||
|
|
december: 11
|
||
|
|
};
|
||
|
|
|
||
|
|
const GREGORIAN_MONTH_ID_TO_ORDER = {
|
||
|
|
january: 1,
|
||
|
|
february: 2,
|
||
|
|
march: 3,
|
||
|
|
april: 4,
|
||
|
|
may: 5,
|
||
|
|
june: 6,
|
||
|
|
july: 7,
|
||
|
|
august: 8,
|
||
|
|
september: 9,
|
||
|
|
october: 10,
|
||
|
|
november: 11,
|
||
|
|
december: 12
|
||
|
|
};
|
||
|
|
|
||
|
|
function normalizeCalendarText(value) {
|
||
|
|
return String(value || "")
|
||
|
|
.normalize("NFKD")
|
||
|
|
.replace(/[\u0300-\u036f]/g, "")
|
||
|
|
.replace(/['`]/g, "")
|
||
|
|
.toLowerCase()
|
||
|
|
.replace(/[^a-z0-9]+/g, " ")
|
||
|
|
.trim();
|
||
|
|
}
|
||
|
|
|
||
|
|
function readNumericPart(parts, partType) {
|
||
|
|
const raw = parts.find((part) => part.type === partType)?.value;
|
||
|
|
if (!raw) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const digits = String(raw).replace(/[^0-9]/g, "");
|
||
|
|
if (!digits) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const parsed = Number(digits);
|
||
|
|
return Number.isFinite(parsed) ? parsed : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function getGregorianMonthOrderFromId(monthId) {
|
||
|
|
if (!monthId) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
const key = String(monthId).trim().toLowerCase();
|
||
|
|
const value = GREGORIAN_MONTH_ID_TO_ORDER[key];
|
||
|
|
return Number.isFinite(value) ? value : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function parseMonthDayStartToken(token) {
|
||
|
|
const match = String(token || "").match(/(\d{2})-(\d{2})/);
|
||
|
|
if (!match) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const month = Number(match[1]);
|
||
|
|
const day = Number(match[2]);
|
||
|
|
if (!Number.isFinite(month) || !Number.isFinite(day)) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
return { month, day };
|
||
|
|
}
|
||
|
|
|
||
|
|
function createDateAtNoon(year, monthIndex, dayOfMonth) {
|
||
|
|
return new Date(Math.trunc(year), monthIndex, Math.trunc(dayOfMonth), 12, 0, 0, 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
function computeWesternEasterDate(year) {
|
||
|
|
const y = Math.trunc(Number(year));
|
||
|
|
if (!Number.isFinite(y)) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const a = y % 19;
|
||
|
|
const b = Math.floor(y / 100);
|
||
|
|
const c = y % 100;
|
||
|
|
const d = Math.floor(b / 4);
|
||
|
|
const e = b % 4;
|
||
|
|
const f = Math.floor((b + 8) / 25);
|
||
|
|
const g = Math.floor((b - f + 1) / 3);
|
||
|
|
const h = (19 * a + b - d - g + 15) % 30;
|
||
|
|
const i = Math.floor(c / 4);
|
||
|
|
const k = c % 4;
|
||
|
|
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
||
|
|
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
||
|
|
const month = Math.floor((h + l - 7 * m + 114) / 31);
|
||
|
|
const day = ((h + l - 7 * m + 114) % 31) + 1;
|
||
|
|
return createDateAtNoon(y, month - 1, day);
|
||
|
|
}
|
||
|
|
|
||
|
|
function computeNthWeekdayOfMonth(year, monthIndex, weekday, ordinal) {
|
||
|
|
const y = Math.trunc(Number(year));
|
||
|
|
if (!Number.isFinite(y)) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const first = createDateAtNoon(y, monthIndex, 1);
|
||
|
|
const firstWeekday = first.getDay();
|
||
|
|
const offset = (weekday - firstWeekday + 7) % 7;
|
||
|
|
const dayOfMonth = 1 + offset + (Math.trunc(ordinal) - 1) * 7;
|
||
|
|
const daysInMonth = new Date(y, monthIndex + 1, 0).getDate();
|
||
|
|
if (dayOfMonth > daysInMonth) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return createDateAtNoon(y, monthIndex, dayOfMonth);
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveGregorianDateRule(rule, selectedYear) {
|
||
|
|
const key = String(rule || "").trim().toLowerCase();
|
||
|
|
if (!key) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (key === "gregorian-easter-sunday") {
|
||
|
|
return computeWesternEasterDate(selectedYear);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (key === "gregorian-good-friday") {
|
||
|
|
const easter = computeWesternEasterDate(selectedYear);
|
||
|
|
if (!(easter instanceof Date) || Number.isNaN(easter.getTime())) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return createDateAtNoon(easter.getFullYear(), easter.getMonth(), easter.getDate() - 2);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (key === "gregorian-thanksgiving-us") {
|
||
|
|
return computeNthWeekdayOfMonth(selectedYear, 10, 4, 4);
|
||
|
|
}
|
||
|
|
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function parseFirstMonthDayFromText(dateText) {
|
||
|
|
const text = String(dateText || "").replace(/~/g, " ");
|
||
|
|
const firstSegment = text.split("/")[0] || text;
|
||
|
|
const match = firstSegment.match(/(January|February|March|April|May|June|July|August|September|October|November|December)\s+(\d{1,2})/i);
|
||
|
|
if (!match) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const monthIndex = MONTH_NAME_TO_INDEX[String(match[1]).toLowerCase()];
|
||
|
|
const day = Number(match[2]);
|
||
|
|
if (!Number.isFinite(monthIndex) || !Number.isFinite(day)) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
return { monthIndex, day };
|
||
|
|
}
|
||
|
|
|
||
|
|
function findHebrewMonthDayInGregorianYear(monthId, day, year) {
|
||
|
|
const aliases = HEBREW_MONTH_ALIAS_BY_ID[String(monthId || "").toLowerCase()] || [];
|
||
|
|
const targetDay = Number(day);
|
||
|
|
if (!aliases.length || !Number.isFinite(targetDay) || !Number.isFinite(year)) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const normalizedAliases = aliases.map((alias) => normalizeCalendarText(alias)).filter(Boolean);
|
||
|
|
const formatter = new Intl.DateTimeFormat("en-u-ca-hebrew", {
|
||
|
|
day: "numeric",
|
||
|
|
month: "long",
|
||
|
|
year: "numeric"
|
||
|
|
});
|
||
|
|
|
||
|
|
const cursor = new Date(Math.trunc(year), 0, 1, 12, 0, 0, 0);
|
||
|
|
const end = new Date(Math.trunc(year), 11, 31, 12, 0, 0, 0);
|
||
|
|
|
||
|
|
while (cursor.getTime() <= end.getTime()) {
|
||
|
|
const parts = formatter.formatToParts(cursor);
|
||
|
|
const currentDay = readNumericPart(parts, "day");
|
||
|
|
const monthName = normalizeCalendarText(parts.find((part) => part.type === "month")?.value);
|
||
|
|
if (currentDay === Math.trunc(targetDay) && normalizedAliases.includes(monthName)) {
|
||
|
|
return new Date(cursor);
|
||
|
|
}
|
||
|
|
cursor.setDate(cursor.getDate() + 1);
|
||
|
|
}
|
||
|
|
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function getIslamicMonthOrderById(monthId, calendarData) {
|
||
|
|
const month = (calendarData?.islamic || []).find((item) => item?.id === monthId);
|
||
|
|
const order = Number(month?.order);
|
||
|
|
return Number.isFinite(order) ? Math.trunc(order) : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function findIslamicMonthDayInGregorianYear(monthId, day, year, calendarData) {
|
||
|
|
const monthOrder = getIslamicMonthOrderById(monthId, calendarData);
|
||
|
|
const targetDay = Number(day);
|
||
|
|
if (!Number.isFinite(monthOrder) || !Number.isFinite(targetDay) || !Number.isFinite(year)) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const formatter = new Intl.DateTimeFormat("en-u-ca-islamic", {
|
||
|
|
day: "numeric",
|
||
|
|
month: "numeric",
|
||
|
|
year: "numeric"
|
||
|
|
});
|
||
|
|
|
||
|
|
const cursor = new Date(Math.trunc(year), 0, 1, 12, 0, 0, 0);
|
||
|
|
const end = new Date(Math.trunc(year), 11, 31, 12, 0, 0, 0);
|
||
|
|
|
||
|
|
while (cursor.getTime() <= end.getTime()) {
|
||
|
|
const parts = formatter.formatToParts(cursor);
|
||
|
|
const currentDay = readNumericPart(parts, "day");
|
||
|
|
const currentMonth = readNumericPart(parts, "month");
|
||
|
|
if (currentDay === Math.trunc(targetDay) && currentMonth === monthOrder) {
|
||
|
|
return new Date(cursor);
|
||
|
|
}
|
||
|
|
cursor.setDate(cursor.getDate() + 1);
|
||
|
|
}
|
||
|
|
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function resolveHolidayGregorianDate(holiday, options = {}) {
|
||
|
|
if (!holiday || typeof holiday !== "object") {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
const selectedYear = Number(options.selectedYear);
|
||
|
|
const calendarData = options.calendarData || {};
|
||
|
|
const calendarId = String(holiday.calendarId || "").trim().toLowerCase();
|
||
|
|
const monthId = String(holiday.monthId || "").trim().toLowerCase();
|
||
|
|
const day = Number(holiday.day);
|
||
|
|
|
||
|
|
if (calendarId === "gregorian") {
|
||
|
|
if (holiday?.dateRule) {
|
||
|
|
const ruledDate = resolveGregorianDateRule(holiday.dateRule, selectedYear);
|
||
|
|
if (ruledDate) {
|
||
|
|
return ruledDate;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const monthDay = parseMonthDayStartToken(holiday.monthDayStart) || parseMonthDayStartToken(holiday.dateText);
|
||
|
|
if (monthDay) {
|
||
|
|
return new Date(selectedYear, monthDay.month - 1, monthDay.day, 12, 0, 0, 0);
|
||
|
|
}
|
||
|
|
const order = getGregorianMonthOrderFromId(monthId);
|
||
|
|
if (Number.isFinite(order) && Number.isFinite(day)) {
|
||
|
|
return new Date(selectedYear, order - 1, Math.trunc(day), 12, 0, 0, 0);
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (calendarId === "hebrew") {
|
||
|
|
return findHebrewMonthDayInGregorianYear(monthId, day, selectedYear);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (calendarId === "islamic") {
|
||
|
|
return findIslamicMonthDayInGregorianYear(monthId, day, selectedYear, calendarData);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (calendarId === "wheel-of-year") {
|
||
|
|
const monthDay = parseMonthDayStartToken(holiday.monthDayStart) || parseFirstMonthDayFromText(holiday.dateText);
|
||
|
|
if (monthDay?.month && monthDay?.day) {
|
||
|
|
return new Date(selectedYear, monthDay.month - 1, monthDay.day, 12, 0, 0, 0);
|
||
|
|
}
|
||
|
|
if (monthDay?.monthIndex != null && monthDay?.day) {
|
||
|
|
return new Date(selectedYear, monthDay.monthIndex, monthDay.day, 12, 0, 0, 0);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatGregorianReferenceDate(date) {
|
||
|
|
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
||
|
|
return "--";
|
||
|
|
}
|
||
|
|
|
||
|
|
return date.toLocaleDateString(undefined, {
|
||
|
|
weekday: "long",
|
||
|
|
year: "numeric",
|
||
|
|
month: "long",
|
||
|
|
day: "numeric"
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatCalendarDateFromGregorian(date, calendarId) {
|
||
|
|
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
||
|
|
return "--";
|
||
|
|
}
|
||
|
|
|
||
|
|
const locale = calendarId === "hebrew"
|
||
|
|
? "en-u-ca-hebrew"
|
||
|
|
: (calendarId === "islamic" ? "en-u-ca-islamic" : "en");
|
||
|
|
|
||
|
|
return new Intl.DateTimeFormat(locale, {
|
||
|
|
weekday: "long",
|
||
|
|
year: "numeric",
|
||
|
|
month: "long",
|
||
|
|
day: "numeric"
|
||
|
|
}).format(date);
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildPlanetMap(planetsObj) {
|
||
|
|
const map = new Map();
|
||
|
|
if (!planetsObj || typeof planetsObj !== "object") {
|
||
|
|
return map;
|
||
|
|
}
|
||
|
|
|
||
|
|
Object.values(planetsObj).forEach((planet) => {
|
||
|
|
if (!planet?.id) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
map.set(planet.id, planet);
|
||
|
|
});
|
||
|
|
|
||
|
|
return map;
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildSignsMap(signs) {
|
||
|
|
const map = new Map();
|
||
|
|
if (!Array.isArray(signs)) {
|
||
|
|
return map;
|
||
|
|
}
|
||
|
|
|
||
|
|
signs.forEach((sign) => {
|
||
|
|
if (!sign?.id) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
map.set(sign.id, sign);
|
||
|
|
});
|
||
|
|
|
||
|
|
return map;
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildGodsMap(magickDataset) {
|
||
|
|
const gods = magickDataset?.grouped?.gods?.gods;
|
||
|
|
const map = new Map();
|
||
|
|
|
||
|
|
if (!Array.isArray(gods)) {
|
||
|
|
return map;
|
||
|
|
}
|
||
|
|
|
||
|
|
gods.forEach((god) => {
|
||
|
|
if (!god?.id) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
map.set(god.id, god);
|
||
|
|
});
|
||
|
|
|
||
|
|
return map;
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildHebrewMap(magickDataset) {
|
||
|
|
const map = new Map();
|
||
|
|
const letters = magickDataset?.grouped?.alphabets?.hebrew;
|
||
|
|
if (!Array.isArray(letters)) {
|
||
|
|
return map;
|
||
|
|
}
|
||
|
|
|
||
|
|
letters.forEach((letter) => {
|
||
|
|
if (!letter?.hebrewLetterId) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
map.set(letter.hebrewLetterId, letter);
|
||
|
|
});
|
||
|
|
|
||
|
|
return map;
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildCalendarData(referenceData) {
|
||
|
|
return {
|
||
|
|
gregorian: Array.isArray(referenceData?.calendarMonths) ? referenceData.calendarMonths : [],
|
||
|
|
hebrew: Array.isArray(referenceData?.hebrewCalendar?.months) ? referenceData.hebrewCalendar.months : [],
|
||
|
|
islamic: Array.isArray(referenceData?.islamicCalendar?.months) ? referenceData.islamicCalendar.months : [],
|
||
|
|
"wheel-of-year": Array.isArray(referenceData?.wheelOfYear?.months) ? referenceData.wheelOfYear.months : []
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function calendarLabel(calendarId) {
|
||
|
|
const key = String(calendarId || "").trim().toLowerCase();
|
||
|
|
if (key === "hebrew") return "Hebrew";
|
||
|
|
if (key === "islamic") return "Islamic";
|
||
|
|
if (key === "wheel-of-year") return "Wheel of the Year";
|
||
|
|
return "Gregorian";
|
||
|
|
}
|
||
|
|
|
||
|
|
function monthLabelForCalendar(calendarData, calendarId, monthId) {
|
||
|
|
const months = calendarData?.[calendarId];
|
||
|
|
if (!Array.isArray(months)) {
|
||
|
|
return monthId || "--";
|
||
|
|
}
|
||
|
|
|
||
|
|
const month = months.find((entry) => String(entry?.id || "").toLowerCase() === String(monthId || "").toLowerCase());
|
||
|
|
return month?.name || monthId || "--";
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeSourceFilter(value) {
|
||
|
|
const key = String(value || "").trim().toLowerCase();
|
||
|
|
if (key === "gregorian" || key === "hebrew" || key === "islamic" || key === "wheel-of-year") {
|
||
|
|
return key;
|
||
|
|
}
|
||
|
|
return "all";
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildAllHolidays(referenceData) {
|
||
|
|
if (Array.isArray(referenceData?.calendarHolidays) && referenceData.calendarHolidays.length) {
|
||
|
|
return [...referenceData.calendarHolidays].sort((left, right) => {
|
||
|
|
const calCmp = calendarLabel(left?.calendarId).localeCompare(calendarLabel(right?.calendarId));
|
||
|
|
if (calCmp !== 0) return calCmp;
|
||
|
|
|
||
|
|
const leftDay = Number(left?.day);
|
||
|
|
const rightDay = Number(right?.day);
|
||
|
|
if (Number.isFinite(leftDay) && Number.isFinite(rightDay) && leftDay !== rightDay) {
|
||
|
|
return leftDay - rightDay;
|
||
|
|
}
|
||
|
|
|
||
|
|
return String(left?.name || "").localeCompare(String(right?.name || ""));
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const legacy = Array.isArray(referenceData?.celestialHolidays) ? referenceData.celestialHolidays : [];
|
||
|
|
return legacy.map((holiday) => ({
|
||
|
|
...holiday,
|
||
|
|
calendarId: "gregorian",
|
||
|
|
dateText: holiday?.date || holiday?.dateRange || ""
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
window.HolidayDataUi = {
|
||
|
|
buildAllHolidays,
|
||
|
|
buildCalendarData,
|
||
|
|
buildGodsMap,
|
||
|
|
buildHebrewMap,
|
||
|
|
buildPlanetMap,
|
||
|
|
buildSignsMap,
|
||
|
|
calendarLabel,
|
||
|
|
formatCalendarDateFromGregorian,
|
||
|
|
formatGregorianReferenceDate,
|
||
|
|
monthLabelForCalendar,
|
||
|
|
normalizeSourceFilter,
|
||
|
|
resolveHolidayGregorianDate
|
||
|
|
};
|
||
|
|
})();
|