kenat
Version:
A JavaScript library for the Ethiopian calendar with date and time support.
568 lines (508 loc) • 23 kB
HTML
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kenat Calendar Example</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: #f9f9f9;
padding: 1rem;
}
.app-container {
display: flex;
gap: 0;
align-items: stretch;
max-width: 1200px;
margin: 0 auto;
background: #fff;
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
#calendar-container {
flex: 1 1 0%;
padding: 2.5rem 2rem 2rem 2rem;
min-width: 0;
}
#calendar-container h1 {
font-size: 2.1rem;
font-weight: 700;
margin-bottom: 1.2rem;
letter-spacing: 0.02em;
color: #2d3a4a;
}
#fasting-sidebar {
width: 300px;
background: linear-gradient(135deg, #f3e7e9 0%, #e3eeff 100%);
padding: 2.5rem 2rem 2rem 2rem;
border-right: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
align-items: flex-start;
min-height: 100%;
}
#fasting-sidebar h2 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 1.2rem;
color: #4a2d7b;
letter-spacing: 0.01em;
}
#fasting-sidebar h3 {
margin-top: 0;
text-align: left;
font-size: 1.1rem;
border-bottom: 1px solid #eee;
padding-bottom: 0.5rem;
margin-bottom: 1.2rem;
color: #2d3a4a;
}
#fasting-list {
list-style: none;
padding: 0;
margin: 0;
text-align: left;
width: 100%;
}
#fasting-list li {
padding: 1rem 1.2rem;
cursor: pointer;
border-radius: 10px;
margin-bottom: 0.7rem;
border-left: 8px solid transparent;
transition: background 0.2s, border-color 0.2s;
font-size: 1.08rem;
background: #f7f7fa;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.03);
}
#fasting-list li:hover {
background: #e3eeff;
}
#fasting-list li.active {
font-weight: bold;
background: linear-gradient(90deg, #e3eeff 60%, #f3e7e9 100%);
border-left-color: #4a2d7b;
color: #4a2d7b;
}
.fasting-day-marker {
background-color: rgba(76, 175, 80, 0.13) ;
box-shadow: 0 0 0 2px #4a2d7b33;
}
table {
border-collapse: collapse;
margin: 0.5rem auto;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
background-color: #fff;
border-radius: 18px;
overflow: hidden;
width: 95%;
max-width: 600px;
}
th,
td {
border: 1px solid #e0e0e0;
padding: 0.25rem;
width: 48px;
height: 48px;
vertical-align: top;
position: relative;
border-radius: 12px;
}
th {
background-color: #f5f5f5;
font-weight: 600;
border-radius: 12px 12px 0 0;
}
.today {
background-color: #fffde7;
border: 2px solid #ffc107;
font-weight: bold;
}
td.event-day {
cursor: pointer;
}
.official-holiday {
background-color: #e3f2fd;
}
.christian-holiday {
background-color: #ebf5ff;
}
.jummah-day {
background-color: #e8f5e9;
}
.holiday-labels {
font-size: 0.75rem;
color: #333;
margin-top: 5px;
line-height: 1.2;
text-align: left;
max-height: 3.6em;
overflow: hidden;
}
.holiday-labels .is-nigs {
font-weight: bold;
color: #b58c00;
}
.holiday-labels .jummah-label {
font-weight: bold;
color: #388e3c;
}
.header-controls,
.controls,
.filter-controls {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
margin: 1rem 0;
align-items: center;
}
.nav-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.controls {
gap: 20px;
}
.filter-controls {
margin-bottom: 1.5rem;
background-color: #fff;
padding: 1rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.filter-controls label,
.controls label {
font-size: 0.9rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
}
.filter-controls label.disabled {
color: #aaa;
cursor: not-allowed;
}
button,
select {
padding: 0.6rem 1.2rem;
font-size: 0.9rem;
cursor: pointer;
border-radius: 6px;
border: 1px solid #ccc;
background-color: #fff;
}
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.5);
justify-content: center;
align-items: center;
}
.modal-content {
background-color: #fefefe;
margin: auto;
padding: 25px;
border: 1px solid #888;
width: 90%;
max-width: 500px;
border-radius: 10px;
position: relative;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
text-align: left;
}
.modal-close {
color: #aaa;
position: absolute;
top: 10px;
right: 20px;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.modal-content h3 {
margin-top: 0;
}
.modal-content .is-nigs-title {
color: #b58c00;
}
.modal-content hr {
border: 0;
border-top: 1px solid #eee;
margin: 15px 0;
}
</style>
</head>
<body>
<div class="app-container">
<div id="fasting-sidebar">
<h2>Fasting Period Selector</h2>
<h3>Choose a Fasting Period</h3>
<ul id="fasting-list"></ul>
</div>
<div id="calendar-container">
<h1>Kenat Calendar</h1>
<div id="calendar">Loading...</div>
</div>
</div>
<div id="holidayModal" class="modal">
<div class="modal-content"><span class="modal-close">×</span>
<div id="modalBody"></div>
</div>
</div>
<script type="module">
import Kenat, { MonthGrid, HolidayTags, toArabic } from '../../src/index.js';
import { monthNames } from '../../src/constants.js';
import { getFastingPeriod } from '../../src/fasting.js';
// State variables
let monthGridInstance;
let useGeez = false;
let weekdayLang = 'amharic';
let weekStart = 1;
let holidayFilter = null;
let activeMode = 'public';
let showAllSaints = false;
let activeFastingRange = null;
const initialDate = new Kenat().getEthiopian();
let currentYear = initialDate.year;
let currentMonth = initialDate.month;
const modal = document.getElementById('holidayModal');
const modalBody = document.getElementById('modalBody');
const closeModal = document.querySelector('.modal-close');
function showHolidayModal(holidays) {
let content = '';
holidays.forEach((h, index) => {
const nigsClass = h.isNigs ? 'is-nigs-title' : '';
const description = h.description ? `<p>${h.description}</p>` : '';
content += `<h3 class="${nigsClass}">${h.name}</h3>${description}`;
if (index < holidays.length - 1) content += '<hr>';
});
modalBody.innerHTML = content;
modal.style.display = 'flex';
}
closeModal.onclick = () => { modal.style.display = 'none'; }
window.onclick = (event) => { if (event.target == modal) modal.style.display = 'none'; }
function isWithinRange(dayObj, range) {
if (!range || !dayObj) return false;
// Accept both Kenat instances and plain objects
let currentDayKenat;
if (dayObj instanceof Kenat) {
currentDayKenat = dayObj;
} else if (dayObj.ethiopianRaw) {
currentDayKenat = new Kenat(dayObj.ethiopianRaw);
} else if (dayObj.ethiopian) {
currentDayKenat = new Kenat(dayObj.ethiopian);
} else {
return false;
}
return (
(currentDayKenat.isSameDay(range.start) || currentDayKenat.isAfter(range.start)) &&
(currentDayKenat.isSameDay(range.end) || currentDayKenat.isBefore(range.end))
);
}
function renderCalendar(gridData) {
let { headers, days, year, month } = gridData;
const holidaysForDay = (item) => item.holidays.map(h => `<div class="${h.isNigs ? 'is-nigs' : ''} ${h.key === 'jummah' ? 'jummah-label' : ''}">${h.name}</div>`).join('');
const yearForComparison = typeof year === 'string' ? toArabic(year) : year;
const yearOptions = Array.from({ length: 201 }, (_, i) => 1900 + i).map(y => `<option value="${y}" ${y === yearForComparison ? 'selected' : ''}>${y}</option>`).join('');
const monthOptions = monthNames.amharic.map((name, i) => `<option value="${i + 1}" ${i + 1 === month ? 'selected' : ''}>${name}</option>`).join('');
let html = `<div class="header-controls"><button id="prevMonth">⬅️</button><div class="nav-controls"><select id="monthSelector">${monthOptions}</select><select id="yearSelector">${yearOptions}</select></div><button id="nextMonth">➡️</button></div><div class="controls" id="topControls"></div><div class="filter-controls" id="filterControls"></div><table><thead><tr>${headers.map(day => `<th>${day}</th>`).join('')}</tr></thead><tbody>`;
for (let i = 0; i < days.length; i += 7) {
html += '<tr>';
for (let j = 0; j < 7; j++) {
const item = days[i + j];
if (!item) {
html += '<td></td>';
} else {
const hasEvents = item.holidays && item.holidays.length > 0;
let dayClasses = [item.isToday ? 'today' : ''];
let inlineStyle = '';
if (hasEvents) {
dayClasses.push('event-day');
if (item.holidays.some(h => h.tags.includes(HolidayTags.PUBLIC))) dayClasses.push('official-holiday');
if (item.holidays.some(h => h.tags.includes(HolidayTags.CHRISTIAN)) && !dayClasses.includes('official-holiday')) dayClasses.push('christian-holiday');
if (item.holidays.some(h => h.key === 'jummah')) dayClasses.push('jummah-day');
}
if (activeFastingRange && isWithinRange(item, activeFastingRange)) {
dayClasses.push('fasting-day-marker');
inlineStyle = `style="background-color: ${activeFastingRange.color}"`;
}
const holidaysText = hasEvents ? holidaysForDay(item) : '';
const eventAttr = hasEvents ? `data-day-index="${i + j}"` : '';
html += `<td class="${dayClasses.join(' ')}" ${eventAttr} ${inlineStyle}><strong>${item.ethiopian.day}</strong><br/><small>${item.gregorian.month}/${item.gregorian.day}</small>${holidaysText ? `<div class="holiday-labels">${holidaysText}</div>` : ''}</td>`;
}
}
html += '</tr>';
}
html += '</tbody></table>';
document.getElementById('calendar').innerHTML = html;
renderControls();
attachEventListeners(days);
}
function renderControls() {
const topControlsContainer = document.getElementById('topControls');
const filterControlsContainer = document.getElementById('filterControls');
topControlsContainer.innerHTML = `<label for="modeSelector">Mode:</label><select id="modeSelector"><option value="public" ${activeMode === 'public' ? 'selected' : ''}>Public</option><option value="christian" ${activeMode === 'christian' ? 'selected' : ''}>Christian</option><option value="muslim" ${activeMode === 'muslim' ? 'selected' : ''}>Muslim</option><option value="none" ${activeMode === 'none' ? 'selected' : ''}>All</option></select><button id="toggleGeez">Geez (${useGeez ? 'ON' : 'OFF'})</button><button id="toggleLang">Lang (${weekdayLang})</button><button id="toggleWeekStart">Start (${weekStart === 1 ? 'Mon' : 'Sun'})</button>`;
let filterHtml = '';
if (activeMode === 'christian') {
filterHtml += `<label><input type="checkbox" id="showAllSaintsToggle" ${showAllSaints ? 'checked' : ''}> Show All Saints</label>`;
}
const isDisabled = activeMode !== 'none';
const disabledAttr = isDisabled ? 'disabled' : '';
filterHtml += `<label class="${isDisabled ? 'disabled' : ''}"><input type="checkbox" name="holidayTag" value="all" ${!holidayFilter || isDisabled ? 'checked' : ''} ${disabledAttr}> All Holidays</label>`;
for (const key in HolidayTags) {
const value = HolidayTags[key];
const isChecked = !isDisabled && holidayFilter && holidayFilter.includes(value);
filterHtml += `<label class="${isDisabled ? 'disabled' : ''}"><input type="checkbox" name="holidayTag" value="${value}" ${isChecked ? 'checked' : ''} ${disabledAttr}> ${value}</label>`;
}
filterControlsContainer.innerHTML = filterHtml;
}
const fastingPeriods = [
{ key: 'ABIY_TSOME', name: 'The Great Lent (Hudade)', color: '#fff59d' },
{ key: 'TSOME_NEBIYAT', name: 'Fast of the Prophets', color: '#b3e5fc' },
{ key: 'TSOME_HAWARYAT', name: 'Fast of the Apostles', color: '#c8e6c9' },
{ key: 'NINEVEH', name: 'Fast of Nineveh', color: '#ffccbc' },
{ key: 'RAMADAN', name: 'Ramadan', color: '#d1c4e9' }
];
function renderFastingSidebar() {
const list = document.getElementById('fasting-list');
list.innerHTML = '';
fastingPeriods.forEach(fast => {
const li = document.createElement('li');
li.textContent = fast.name;
li.style.borderLeftColor = fast.color;
if (activeFastingRange && activeFastingRange.key === fast.key) {
li.classList.add('active');
}
li.onclick = () => {
const yearToSearch = currentYear;
const period = getFastingPeriod(fast.key, yearToSearch);
if (period) {
if (activeFastingRange && activeFastingRange.key === fast.key) {
activeFastingRange = null;
} else {
activeFastingRange = {
key: fast.key,
start: new Kenat(period.start),
end: new Kenat(period.end),
color: fast.color
};
currentYear = activeFastingRange.start.getEthiopian().year;
currentMonth = activeFastingRange.start.getEthiopian().month;
}
} else {
alert(`Fasting period for ${fast.name} not found in ${yearToSearch} E.C.`);
activeFastingRange = null;
}
rerender();
};
list.appendChild(li);
});
// Fasting info display
let infoBox = document.getElementById('fasting-info-box');
if (!infoBox) {
infoBox = document.createElement('div');
infoBox.id = 'fasting-info-box';
infoBox.style.marginTop = '1.5rem';
infoBox.style.padding = '1rem';
infoBox.style.background = '#f7f7fa';
infoBox.style.borderRadius = '10px';
infoBox.style.boxShadow = '0 1px 4px rgba(0,0,0,0.03)';
infoBox.style.fontSize = '1rem';
infoBox.style.color = '#2d3a4a';
list.parentNode.appendChild(infoBox);
}
if (activeFastingRange) {
const start = activeFastingRange.start.getEthiopian();
const end = activeFastingRange.end.getEthiopian();
const days = Kenat.generateDateRange(activeFastingRange.start, activeFastingRange.end).length;
const fastName = fastingPeriods.find(f => f.key === activeFastingRange.key).name;
// Use a colored dot for the fast color, but keep text readable
infoBox.innerHTML = `<strong>Selected Fast:</strong> <span style="display:inline-flex;align-items:center;"><span style="display:inline-block;width:14px;height:14px;border-radius:50%;background:${activeFastingRange.color};margin-right:7px;border:1px solid #ccc;"></span><span style="color:#2d3a4a;">${fastName}</span></span><br>
<strong>Start:</strong> ${start.month}/${start.day}/${start.year}<br>
<strong>End:</strong> ${end.month}/${end.day}/${end.year}<br>
<strong>Days:</strong> ${days}`;
infoBox.style.display = 'block';
} else {
infoBox.innerHTML = '';
infoBox.style.display = 'none';
}
}
function attachEventListeners(days) {
// ✨ FIX: The line `activeFastingRange = null;` has been REMOVED from the four
// navigation handlers below to ensure the highlight persists between months.
document.getElementById('prevMonth').onclick = () => {
const newGrid = monthGridInstance.down().generate();
currentYear = (typeof newGrid.year === 'string') ? toArabic(newGrid.year) : newGrid.year;
currentMonth = newGrid.month;
rerender();
};
document.getElementById('nextMonth').onclick = () => {
const newGrid = monthGridInstance.up().generate();
currentYear = (typeof newGrid.year === 'string') ? toArabic(newGrid.year) : newGrid.year;
currentMonth = newGrid.month;
rerender();
};
document.getElementById('monthSelector').onchange = (e) => {
currentMonth = parseInt(e.target.value);
rerender();
};
document.getElementById('yearSelector').onchange = (e) => {
currentYear = parseInt(e.target.value);
rerender();
};
// --- Other event listeners (no changes needed here) ---
document.getElementById('toggleGeez').onclick = () => { useGeez = !useGeez; rerender(); };
document.getElementById('toggleLang').onclick = () => { weekdayLang = weekdayLang === 'amharic' ? 'english' : 'amharic'; rerender(); };
document.getElementById('toggleWeekStart').onclick = () => { weekStart = weekStart === 1 ? 0 : 1; rerender(); };
document.getElementById('modeSelector').onchange = (e) => { activeMode = e.target.value; if (activeMode !== 'none') { holidayFilter = null; } showAllSaints = false; rerender(); };
const saintsToggle = document.getElementById('showAllSaintsToggle');
if (saintsToggle) { saintsToggle.onchange = (e) => { showAllSaints = e.target.checked; rerender(); }; }
document.querySelectorAll('.event-day').forEach(cell => {
cell.onclick = () => {
const dayIndex = parseInt(cell.getAttribute('data-day-index'));
const dayData = days[dayIndex];
if (dayData && dayData.holidays.length > 0) { showHolidayModal(dayData.holidays); }
};
});
const allCheckbox = document.querySelector('input[name="holidayTag"][value="all"]');
const otherCheckboxes = document.querySelectorAll('input[name="holidayTag"]:not([value="all"])');
const updateFilter = () => {
const checkedValues = Array.from(otherCheckboxes).filter(i => i.checked).map(i => i.value);
if (checkedValues.length === 0) { allCheckbox.checked = true; holidayFilter = null; }
else { allCheckbox.checked = false; holidayFilter = checkedValues; }
rerender();
};
if (allCheckbox) {
allCheckbox.onchange = () => {
if (allCheckbox.checked) { otherCheckboxes.forEach(cb => cb.checked = false); holidayFilter = null; rerender(); }
else { allCheckbox.checked = true; }
};
}
otherCheckboxes.forEach(checkbox => { if (checkbox) checkbox.onchange = updateFilter; });
}
function rerender() {
monthGridInstance = new MonthGrid({
year: currentYear,
month: currentMonth,
useGeez, weekdayLang, weekStart,
holidayFilter,
mode: activeMode,
showAllSaints: showAllSaints,
});
renderCalendar(monthGridInstance.generate());
renderFastingSidebar();
}
rerender();
</script>
</body>
</html>