Files
TaroTime/app/ui-calendar.js

759 lines
21 KiB
JavaScript
Raw Normal View History

2026-03-07 01:09:00 -08:00
/* ui-calendar.js — Month and celestial holiday browser */
(function () {
"use strict";
const { getTarotCardDisplayName, getTarotCardSearchAliases } = window.TarotCardImages || {};
2026-03-07 05:17:50 -08:00
const calendarDatesUi = window.TarotCalendarDates || {};
const calendarDetailUi = window.TarotCalendarDetail || {};
2026-03-07 13:38:13 -08:00
const calendarDataUi = window.CalendarDataUi || {};
2026-03-07 05:17:50 -08:00
const {
addDays,
buildSignDateBounds,
formatCalendarDateFromGregorian,
formatDateLabel,
formatGregorianReferenceDate,
formatIsoDate,
getDaysInMonth,
getGregorianMonthStartDate,
getGregorianReferenceDateForCalendarMonth,
getMonthStartWeekday,
intersectDateRanges,
isMonthDayInRange,
isoToDateAtNoon,
normalizeCalendarText,
parseDayRangeFromText,
parseMonthDayStartToken,
parseMonthDayToken,
parseMonthDayTokensFromText,
parseMonthRange,
resolveCalendarDayToGregorian,
resolveHolidayGregorianDate
} = calendarDatesUi;
2026-03-07 01:09:00 -08:00
2026-03-07 13:38:13 -08:00
if (
typeof calendarDataUi.getMonthDayLinkRows !== "function"
|| typeof calendarDataUi.buildDecanTarotRowsForMonth !== "function"
|| typeof calendarDataUi.eventSearchText !== "function"
|| typeof calendarDataUi.holidaySearchText !== "function"
|| typeof calendarDataUi.buildHolidayList !== "function"
|| typeof calendarDataUi.buildMonthSearchText !== "function"
) {
throw new Error("CalendarDataUi module must load before ui-calendar.js");
}
2026-03-07 01:09:00 -08:00
const state = {
initialized: false,
referenceData: null,
magickDataset: null,
selectedCalendar: "gregorian",
calendarData: {},
months: [],
filteredMonths: [],
holidays: [],
calendarHolidays: [],
selectedMonthId: null,
searchQuery: "",
selectedYear: new Date().getFullYear(),
selectedDayMonthId: null,
selectedDayCalendarId: null,
selectedDayEntries: [],
planetsById: new Map(),
signsById: new Map(),
godsById: new Map(),
hebrewById: new Map(),
dayLinksCache: new Map()
};
const TAROT_TRUMP_NUMBER_BY_NAME = {
"the fool": 0,
fool: 0,
"the magus": 1,
magus: 1,
magician: 1,
"the high priestess": 2,
"high priestess": 2,
"the empress": 3,
empress: 3,
"the emperor": 4,
emperor: 4,
"the hierophant": 5,
hierophant: 5,
"the lovers": 6,
lovers: 6,
"the chariot": 7,
chariot: 7,
strength: 8,
lust: 8,
"the hermit": 9,
hermit: 9,
fortune: 10,
"wheel of fortune": 10,
justice: 11,
"the hanged man": 12,
"hanged man": 12,
death: 13,
temperance: 14,
art: 14,
"the devil": 15,
devil: 15,
"the tower": 16,
tower: 16,
"the star": 17,
star: 17,
"the moon": 18,
moon: 18,
"the sun": 19,
sun: 19,
aeon: 20,
judgement: 20,
judgment: 20,
universe: 21,
world: 21,
"the world": 21
};
function normalizeText(value) {
return String(value || "").trim();
}
function normalizeSearchValue(value) {
2026-03-07 05:17:50 -08:00
return normalizeText(value).toLowerCase();
2026-03-07 01:09:00 -08:00
}
function cap(value) {
const text = normalizeText(value);
return text ? text.charAt(0).toUpperCase() + text.slice(1) : text;
}
function normalizeTarotName(value) {
2026-03-07 05:17:50 -08:00
return normalizeText(value)
2026-03-07 01:09:00 -08:00
.toLowerCase()
.replace(/\s+/g, " ");
}
function resolveTarotTrumpNumber(cardName) {
const key = normalizeTarotName(cardName);
if (!key) {
return null;
}
2026-03-07 05:17:50 -08:00
2026-03-07 01:09:00 -08:00
if (Object.prototype.hasOwnProperty.call(TAROT_TRUMP_NUMBER_BY_NAME, key)) {
return TAROT_TRUMP_NUMBER_BY_NAME[key];
}
2026-03-07 05:17:50 -08:00
2026-03-07 01:09:00 -08:00
const withoutLeadingThe = key.replace(/^the\s+/, "");
if (Object.prototype.hasOwnProperty.call(TAROT_TRUMP_NUMBER_BY_NAME, withoutLeadingThe)) {
return TAROT_TRUMP_NUMBER_BY_NAME[withoutLeadingThe];
}
2026-03-07 05:17:50 -08:00
2026-03-07 01:09:00 -08:00
return null;
}
function getDisplayTarotName(cardName, trumpNumber) {
if (!cardName) {
return "";
}
if (typeof getTarotCardDisplayName !== "function") {
return cardName;
}
if (Number.isFinite(Number(trumpNumber))) {
return getTarotCardDisplayName(cardName, { trumpNumber: Number(trumpNumber) }) || cardName;
}
return getTarotCardDisplayName(cardName) || cardName;
}
2026-03-07 05:17:50 -08:00
function normalizeMinorTarotCardName(value) {
return normalizeTarotName(value)
.split(" ")
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
2026-03-07 01:09:00 -08:00
}
2026-03-07 05:17:50 -08:00
function normalizeDayFilterEntry(dayNumber, gregorianIso) {
const nextDayNumber = Math.trunc(Number(dayNumber));
const nextIso = normalizeText(gregorianIso);
if (!Number.isFinite(nextDayNumber) || nextDayNumber <= 0 || !nextIso) {
2026-03-07 01:09:00 -08:00
return null;
}
2026-03-07 05:17:50 -08:00
return {
dayNumber: nextDayNumber,
gregorianIso: nextIso
};
2026-03-07 01:09:00 -08:00
}
2026-03-07 05:17:50 -08:00
function sortDayFilterEntries(entries) {
const deduped = new Map();
(Array.isArray(entries) ? entries : []).forEach((entry) => {
const normalized = normalizeDayFilterEntry(entry?.dayNumber, entry?.gregorianIso);
if (normalized) {
deduped.set(normalized.dayNumber, normalized);
}
});
2026-03-07 01:09:00 -08:00
2026-03-07 05:17:50 -08:00
return [...deduped.values()].sort((left, right) => left.dayNumber - right.dayNumber);
2026-03-07 01:09:00 -08:00
}
2026-03-07 05:17:50 -08:00
function getElements() {
2026-03-07 01:09:00 -08:00
return {
2026-03-07 05:17:50 -08:00
monthListEl: document.getElementById("calendar-month-list"),
monthCountEl: document.getElementById("calendar-month-count"),
listTitleEl: document.getElementById("calendar-list-title"),
calendarTypeEl: document.getElementById("calendar-type-select"),
calendarYearWrapEl: document.getElementById("calendar-year-wrap"),
yearInputEl: document.getElementById("calendar-year-input"),
searchInputEl: document.getElementById("calendar-search-input"),
searchClearEl: document.getElementById("calendar-search-clear"),
detailNameEl: document.getElementById("calendar-detail-name"),
detailSubEl: document.getElementById("calendar-detail-sub"),
detailBodyEl: document.getElementById("calendar-detail-body")
2026-03-07 01:09:00 -08:00
};
}
function ensureDayFilterContext(month) {
if (!month) {
return;
}
const sameContext = state.selectedDayMonthId === month.id
&& state.selectedDayCalendarId === state.selectedCalendar;
if (!sameContext) {
state.selectedDayMonthId = month.id;
state.selectedDayCalendarId = state.selectedCalendar;
state.selectedDayEntries = [];
}
}
function clearSelectedDayFilter() {
state.selectedDayMonthId = null;
state.selectedDayCalendarId = null;
state.selectedDayEntries = [];
}
function toggleDayFilterEntry(month, dayNumber, gregorianIso) {
ensureDayFilterContext(month);
const next = normalizeDayFilterEntry(dayNumber, gregorianIso);
if (!next) {
return;
}
2026-03-07 05:17:50 -08:00
const entries = [...state.selectedDayEntries];
2026-03-07 01:09:00 -08:00
const existingIndex = entries.findIndex((entry) => entry.dayNumber === next.dayNumber);
if (existingIndex >= 0) {
entries.splice(existingIndex, 1);
} else {
entries.push(next);
}
state.selectedDayEntries = sortDayFilterEntries(entries);
}
function toggleDayRangeFilter(month, startDay, endDay) {
ensureDayFilterContext(month);
const start = Math.trunc(Number(startDay));
const end = Math.trunc(Number(endDay));
if (!Number.isFinite(start) || !Number.isFinite(end) || start <= 0 || end <= 0) {
return;
}
const minDay = Math.min(start, end);
const maxDay = Math.max(start, end);
const rows = getMonthDayLinkRows(month)
.filter((row) => row.isResolved && row.day >= minDay && row.day <= maxDay)
.map((row) => normalizeDayFilterEntry(row.day, row.gregorianDate))
.filter(Boolean);
if (!rows.length) {
return;
}
const existingSet = new Set(state.selectedDayEntries.map((entry) => entry.dayNumber));
const allSelected = rows.every((row) => existingSet.has(row.dayNumber));
if (allSelected) {
const removeSet = new Set(rows.map((row) => row.dayNumber));
state.selectedDayEntries = state.selectedDayEntries.filter((entry) => !removeSet.has(entry.dayNumber));
return;
}
rows.forEach((row) => {
if (!existingSet.has(row.dayNumber)) {
state.selectedDayEntries.push(row);
}
});
state.selectedDayEntries = sortDayFilterEntries(state.selectedDayEntries);
}
function getSelectedDayFilterContext(month) {
if (!month) {
return null;
}
if (state.selectedDayMonthId !== month.id) {
return null;
}
if (state.selectedDayCalendarId !== state.selectedCalendar) {
return null;
}
if (!Array.isArray(state.selectedDayEntries) || !state.selectedDayEntries.length) {
return null;
}
const entries = state.selectedDayEntries
.map((entry) => {
const normalized = normalizeDayFilterEntry(entry.dayNumber, entry.gregorianIso);
if (!normalized) {
return null;
}
return {
...normalized,
gregorianDate: isoToDateAtNoon(normalized.gregorianIso)
};
})
.filter(Boolean);
if (!entries.length) {
return null;
}
return {
entries,
dayNumbers: new Set(entries.map((entry) => entry.dayNumber))
};
}
function buildPlanetMap(planetsObj) {
const map = new Map();
if (!planetsObj || typeof planetsObj !== "object") {
return map;
}
Object.values(planetsObj).forEach((planet) => {
2026-03-07 05:17:50 -08:00
if (planet?.id) {
map.set(planet.id, planet);
2026-03-07 01:09:00 -08:00
}
});
return map;
}
function buildSignsMap(signs) {
const map = new Map();
if (!Array.isArray(signs)) {
return map;
}
signs.forEach((sign) => {
2026-03-07 05:17:50 -08:00
if (sign?.id) {
map.set(sign.id, sign);
2026-03-07 01:09:00 -08:00
}
});
return map;
}
function buildGodsMap(magickDataset) {
const gods = magickDataset?.grouped?.gods?.gods;
const map = new Map();
if (!Array.isArray(gods)) {
return map;
}
gods.forEach((god) => {
2026-03-07 05:17:50 -08:00
if (god?.id) {
map.set(god.id, god);
2026-03-07 01:09:00 -08:00
}
});
return map;
}
function findGodIdByName(name) {
2026-03-07 05:17:50 -08:00
if (!name) {
return null;
}
const normalized = normalizeText(name).toLowerCase().replace(/^the\s+/, "");
2026-03-07 01:09:00 -08:00
for (const [id, god] of state.godsById) {
2026-03-07 05:17:50 -08:00
const godName = normalizeText(god?.name).toLowerCase().replace(/^the\s+/, "");
if (godName === normalized || id.toLowerCase() === normalized) {
return id;
}
2026-03-07 01:09:00 -08:00
}
2026-03-07 05:17:50 -08:00
2026-03-07 01:09:00 -08:00
return null;
}
function buildHebrewMap(magickDataset) {
const map = new Map();
const letters = magickDataset?.grouped?.alphabets?.hebrew;
if (!Array.isArray(letters)) {
return map;
}
letters.forEach((letter) => {
2026-03-07 05:17:50 -08:00
if (letter?.hebrewLetterId) {
map.set(letter.hebrewLetterId, letter);
2026-03-07 01:09:00 -08:00
}
});
return map;
}
function sortMonths(months) {
return [...months].sort((left, right) => Number(left?.order || 0) - Number(right?.order || 0));
}
function getSelectedMonth() {
return state.months.find((month) => month.id === state.selectedMonthId) || null;
}
2026-03-07 05:17:50 -08:00
function getMonthSubtitle(month) {
if (state.selectedCalendar === "hebrew" || state.selectedCalendar === "islamic") {
const native = month.nativeName ? ` · ${month.nativeName}` : "";
const days = month.days ? ` · ${month.days} days` : "";
return `${month.season || ""}${native}${days}`;
2026-03-07 01:09:00 -08:00
}
2026-03-07 05:17:50 -08:00
if (state.selectedCalendar === "wheel-of-year") {
return [month.date, month.type, month.season].filter(Boolean).join(" · ");
2026-03-07 01:09:00 -08:00
}
2026-03-07 05:17:50 -08:00
return parseMonthRange(month);
2026-03-07 01:09:00 -08:00
}
2026-03-07 05:17:50 -08:00
function renderList(elements) {
const { monthListEl, monthCountEl, listTitleEl } = elements;
if (!monthListEl) {
return;
2026-03-07 01:09:00 -08:00
}
2026-03-07 05:17:50 -08:00
monthListEl.innerHTML = "";
2026-03-07 01:09:00 -08:00
2026-03-07 05:17:50 -08:00
state.filteredMonths.forEach((month) => {
const isSelected = month.id === state.selectedMonthId;
const itemEl = document.createElement("div");
itemEl.className = `planet-list-item${isSelected ? " is-selected" : ""}`;
itemEl.setAttribute("role", "option");
itemEl.setAttribute("aria-selected", isSelected ? "true" : "false");
itemEl.dataset.monthId = month.id;
itemEl.innerHTML = `
<div class="planet-list-name">${month.name || month.id}</div>
<div class="planet-list-meta">${getMonthSubtitle(month)}</div>
`;
itemEl.addEventListener("click", () => {
selectByMonthId(month.id, elements);
});
monthListEl.appendChild(itemEl);
});
2026-03-07 01:09:00 -08:00
2026-03-07 05:17:50 -08:00
if (monthCountEl) {
monthCountEl.textContent = state.searchQuery
? `${state.filteredMonths.length} of ${state.months.length} months`
: `${state.months.length} months`;
2026-03-07 01:09:00 -08:00
}
2026-03-07 05:17:50 -08:00
if (listTitleEl) {
listTitleEl.textContent = "Calendar > Months";
2026-03-07 01:09:00 -08:00
}
}
2026-03-07 13:38:13 -08:00
function getCalendarDataContext() {
return {
state,
normalizeText,
normalizeSearchValue,
normalizeMinorTarotCardName,
getTarotCardSearchAliases,
addDays,
buildSignDateBounds,
formatDateLabel,
formatIsoDate,
getDaysInMonth,
resolveCalendarDayToGregorian,
resolveHolidayGregorianDate
};
}
function buildDecanTarotRowsForMonth(month) {
return calendarDataUi.buildDecanTarotRowsForMonth(getCalendarDataContext(), month);
}
2026-03-07 01:09:00 -08:00
2026-03-07 13:38:13 -08:00
function getMonthDayLinkRows(month) {
return calendarDataUi.getMonthDayLinkRows(getCalendarDataContext(), month);
2026-03-07 01:09:00 -08:00
}
2026-03-07 05:17:50 -08:00
function eventSearchText(event) {
2026-03-07 13:38:13 -08:00
return calendarDataUi.eventSearchText(getCalendarDataContext(), event);
2026-03-07 05:17:50 -08:00
}
2026-03-07 01:09:00 -08:00
2026-03-07 05:17:50 -08:00
function holidaySearchText(holiday) {
2026-03-07 13:38:13 -08:00
return calendarDataUi.holidaySearchText(getCalendarDataContext(), holiday);
2026-03-07 05:17:50 -08:00
}
2026-03-07 01:09:00 -08:00
2026-03-07 05:17:50 -08:00
function buildHolidayList(month) {
2026-03-07 13:38:13 -08:00
return calendarDataUi.buildHolidayList(getCalendarDataContext(), month);
2026-03-07 01:09:00 -08:00
}
function buildMonthSearchText(month) {
2026-03-07 13:38:13 -08:00
return calendarDataUi.buildMonthSearchText(getCalendarDataContext(), month);
2026-03-07 01:09:00 -08:00
}
function matchesSearch(searchText) {
if (!state.searchQuery) {
return true;
}
return searchText.includes(state.searchQuery);
}
function syncSearchControls(elements) {
if (elements.searchInputEl) {
elements.searchInputEl.value = state.searchQuery;
}
if (elements.searchClearEl) {
elements.searchClearEl.disabled = !state.searchQuery;
}
}
2026-03-07 05:17:50 -08:00
function renderDetail(elements) {
calendarDetailUi.renderDetail?.(elements);
}
2026-03-07 01:09:00 -08:00
function applySearchFilter(elements) {
state.filteredMonths = state.searchQuery
? state.months.filter((month) => matchesSearch(buildMonthSearchText(month)))
: [...state.months];
if (!state.filteredMonths.some((month) => month.id === state.selectedMonthId)) {
state.selectedMonthId = state.filteredMonths[0]?.id || null;
}
syncSearchControls(elements);
renderList(elements);
renderDetail(elements);
}
function selectByMonthId(monthId, elements = getElements()) {
const target = state.months.find((month) => month.id === monthId);
if (!target) {
return false;
}
if (state.searchQuery && !state.filteredMonths.some((month) => month.id === target.id)) {
state.searchQuery = "";
state.filteredMonths = [...state.months];
}
if (state.selectedMonthId !== target.id) {
clearSelectedDayFilter();
}
state.selectedMonthId = target.id;
syncSearchControls(elements);
renderList(elements);
renderDetail(elements);
return true;
}
function selectCalendarType(calendarId, elements = getElements()) {
if (!state.calendarData || !Array.isArray(state.calendarData[calendarId])) {
return false;
}
if (elements.calendarTypeEl) {
elements.calendarTypeEl.value = calendarId;
}
loadCalendarType(calendarId, elements);
return true;
}
function bindYearInput(elements) {
if (!elements.yearInputEl) {
return;
}
elements.yearInputEl.value = String(state.selectedYear);
elements.yearInputEl.addEventListener("change", () => {
const nextYear = Number(elements.yearInputEl.value);
if (!Number.isFinite(nextYear)) {
elements.yearInputEl.value = String(state.selectedYear);
return;
}
state.selectedYear = Math.min(2500, Math.max(1900, Math.round(nextYear)));
elements.yearInputEl.value = String(state.selectedYear);
state.dayLinksCache = new Map();
clearSelectedDayFilter();
renderDetail(elements);
});
}
function bindSearchInput(elements) {
if (elements.searchInputEl) {
elements.searchInputEl.addEventListener("input", () => {
state.searchQuery = normalizeSearchValue(elements.searchInputEl.value);
applySearchFilter(elements);
});
}
if (elements.searchClearEl && elements.searchInputEl) {
elements.searchClearEl.addEventListener("click", () => {
state.searchQuery = "";
elements.searchInputEl.value = "";
applySearchFilter(elements);
elements.searchInputEl.focus();
});
}
}
function loadCalendarType(calendarId, elements) {
const months = state.calendarData[calendarId];
if (!Array.isArray(months)) {
return;
}
state.selectedCalendar = calendarId;
state.dayLinksCache = new Map();
clearSelectedDayFilter();
state.months = sortMonths(months);
state.filteredMonths = [...state.months];
state.selectedMonthId = state.months[0]?.id || null;
state.searchQuery = "";
if (elements.calendarYearWrapEl) {
elements.calendarYearWrapEl.hidden = false;
}
syncSearchControls(elements);
applySearchFilter(elements);
}
function bindCalendarTypeSelect(elements) {
if (!elements.calendarTypeEl) {
return;
}
elements.calendarTypeEl.value = state.selectedCalendar;
elements.calendarTypeEl.addEventListener("change", () => {
2026-03-07 05:17:50 -08:00
loadCalendarType(String(elements.calendarTypeEl.value || "gregorian"), elements);
2026-03-07 01:09:00 -08:00
});
}
function ensureCalendarSection(referenceData, magickDataset) {
if (!referenceData) {
return;
}
2026-03-07 05:17:50 -08:00
calendarDatesUi.init?.({
getSelectedYear: () => state.selectedYear,
getSelectedCalendar: () => state.selectedCalendar,
getIslamicMonths: () => state.calendarData?.islamic || []
});
calendarDetailUi.init?.({
getState: () => state,
getElements,
getSelectedMonth,
getSelectedDayFilterContext,
clearSelectedDayFilter,
toggleDayFilterEntry,
toggleDayRangeFilter,
getMonthSubtitle,
getMonthDayLinkRows,
buildDecanTarotRowsForMonth,
buildHolidayList,
matchesSearch,
eventSearchText,
holidaySearchText,
getDisplayTarotName,
cap,
formatGregorianReferenceDate,
getDaysInMonth,
getMonthStartWeekday,
getGregorianMonthStartDate,
formatCalendarDateFromGregorian,
parseMonthDayToken,
parseMonthDayTokensFromText,
parseMonthDayStartToken,
parseDayRangeFromText,
parseMonthRange,
formatIsoDate,
resolveHolidayGregorianDate,
isMonthDayInRange,
intersectDateRanges,
getGregorianReferenceDateForCalendarMonth,
normalizeCalendarText,
findGodIdByName
});
2026-03-07 01:09:00 -08:00
state.referenceData = referenceData;
state.magickDataset = magickDataset || null;
state.dayLinksCache = new Map();
clearSelectedDayFilter();
state.holidays = Array.isArray(referenceData.celestialHolidays) ? referenceData.celestialHolidays : [];
state.calendarHolidays = Array.isArray(referenceData.calendarHolidays) ? referenceData.calendarHolidays : [];
state.planetsById = buildPlanetMap(referenceData.planets);
state.signsById = buildSignsMap(referenceData.signs);
state.godsById = buildGodsMap(state.magickDataset);
state.hebrewById = buildHebrewMap(state.magickDataset);
state.calendarData = {
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 : []
};
const currentCalMonths = state.calendarData[state.selectedCalendar] || state.calendarData.gregorian || [];
state.months = sortMonths(currentCalMonths);
state.filteredMonths = [...state.months];
const elements = getElements();
if (elements.calendarYearWrapEl) {
elements.calendarYearWrapEl.hidden = false;
}
if (!state.months.length) {
if (elements.detailNameEl) {
elements.detailNameEl.textContent = "Calendar";
}
if (elements.detailSubEl) {
elements.detailSubEl.textContent = "No month data available.";
}
if (elements.detailBodyEl) {
elements.detailBodyEl.innerHTML = "";
}
if (elements.monthListEl) {
elements.monthListEl.innerHTML = "";
}
return;
}
if (!state.selectedMonthId || !state.months.some((month) => month.id === state.selectedMonthId)) {
state.selectedMonthId = state.months[0].id;
}
if (!state.initialized) {
state.initialized = true;
bindYearInput(elements);
bindSearchInput(elements);
bindCalendarTypeSelect(elements);
}
applySearchFilter(elements);
}
window.CalendarSectionUi = {
ensureCalendarSection,
selectByMonthId,
selectCalendarType
};
})();