<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Haunted Inc. Events</title>
<link href="https://fonts.googleapis.com/css2?family=Creepster&family=Jolly+Ledger&family=Syne+Mono&display=swap" rel="stylesheet" />
<style>
body {
font-family: 'Syne Mono', monospace;
background-color: #000;
margin: 0;
padding: 20px 10px;
max-width: 90vw;
min-width: 300px;
margin-left: auto;
margin-right: auto;
color: #fff;
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1 {
font-family: 'Creepster', cursive;
font-weight: 700;
font-size: 3rem;
margin-bottom: 0.3em;
color: #C11C84;
text-align: center;
user-select: none;
}
h2.section-title {
font-family: 'Jolly Ledger', cursive;
font-size: 2rem;
font-weight: 700;
color: #C11C84;
margin: 0 0 0.75em 0;
text-align: center;
user-select: none;
}
#calendar {
max-width: 100%;
display: flex;
flex-direction: column;
gap: 2rem;
}
.events-list, .upcoming-list {
background-color: #1a1a1a;
border-radius: 16px;
padding: 16px 24px;
box-shadow: 2px 4px 8px rgba(193, 28, 132, 0.5);
font-family: 'Syne Mono', monospace;
font-size: 1rem;
color: #fff;
max-height: 400px;
overflow-y: auto;
}
.events-list {
min-height: 100px;
}
.event-item {
padding: 14px 10px;
border-bottom: 1px solid #4c004a;
cursor: pointer;
user-select: none;
transition: background-color 0.3s ease;
display: flex;
flex-direction: column;
gap: 6px;
}
.event-item:last-child {
border-bottom: none;
}
.event-item:hover,
.event-item:focus-visible {
background-color: #350035;
outline: none;
}
.event-summary {
display: flex;
flex-direction: column;
gap: 4px;
word-wrap: break-word;
}
.event-title {
font-family: 'Jolly Ledger', cursive;
font-size: 1.3rem;
font-weight: 700;
color: #f8bbd0;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.plus18-label {
border: 2px solid #ff4081;
border-radius: 12px;
padding: 2px 8px;
font-size: 0.8rem;
font-weight: 700;
color: #ff4081;
user-select: none;
white-space: nowrap;
}
.event-time, .event-streamers {
font-weight: 600;
color: #e0a4ce;
font-size: 0.95rem;
white-space: nowrap;
}
.event-details {
margin-top: 10px;
padding-left: 8px;
font-family: 'Syne Mono', monospace;
font-size: 0.9rem;
color: #ddd;
line-height: 1.4em;
display: none;
word-wrap: break-word;
}
.event-details strong {
color: #C11C84;
}
.event-item.expanded .event-details {
display: block;
}
@media (min-width: 480px) {
.event-item {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.event-summary {
flex-direction: row;
gap: 16px;
flex-wrap: wrap;
align-items: center;
}
.event-title {
white-space: nowrap;
flex-grow: 1;
overflow: hidden;
text-overflow: ellipsis;
}
}
</style>
</head>
<body>
<h1>Haunted Inc. Events</h1>
<div id="calendar" role="region" aria-label="Events Calendar">
<section aria-label="Today’s events">
<h2 class="section-title">Today</h2>
<div id="today-events" class="events-list" aria-live="polite" tabindex="0">
Loading...
</div>
</section>
<section aria-label="Upcoming events">
<h2 class="section-title">Upcoming</h2>
<div id="upcoming-events" class="upcoming-list" aria-live="polite" tabindex="0">
Loading...
</div>
</section>
</div>
<script>
// Converts array of event rows into event objects with Date instances
function createEvents(data) {
if (!Array.isArray(data)) return [];
return data.map(row => {
if (!row.Date || !row.Time) return null;
const [year, month, day] = row.Date.split('-').map(Number);
const [hours, minutes] = row.Time.split(':').map(Number);
const dateTime = new Date(year, month - 1, day, hours, minutes);
if (isNaN(dateTime.getTime())) return null;
return {
id: 'evt_' + Math.random().toString(36).slice(2),
title: row.EventName || '',
datetime: dateTime,
plus18: Boolean(row.Plus18),
streamers: row.Streamers || '',
location: row.Location || '',
description: row.Description || ''
};
}).filter(e => e !== null);
}
// Format a Date object to readable date string
function formatDate(date) {
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
// Format a Date object to readable time string
function formatTime(date) {
return date.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
// Check if two dates represent the same local calendar day
function isSameDay(d1, d2) {
return d1.getFullYear() === d2.getFullYear() &&
d1.getMonth() === d2.getMonth() &&
d1.getDate() === d2.getDate();
}
// Render events inside a container element
function renderEvents(container, events, isExpandable = true) {
container.innerHTML = '';
if (events.length === 0) {
container.textContent = 'No events.';
return;
}
events.forEach(e => {
const item = document.createElement('div');
item.className = 'event-item';
item.tabIndex = 0;
item.setAttribute('role', isExpandable ? 'button' : 'article');
item.setAttribute('aria-expanded', 'false');
item.setAttribute('aria-label', e.title + (e.plus18 ? ', 18 Plus Stream' : '') + `, at ${formatTime(e.datetime)}`);
const summary = document.createElement('div');
summary.className = 'event-summary';
const title = document.createElement('div');
title.className = 'event-title';
title.textContent = e.title;
if (e.plus18) {
const plusLabel = document.createElement('span');
plusLabel.className = 'plus18-label';
plusLabel.textContent = '18 Plus Stream';
title.appendChild(plusLabel);
}
const time = document.createElement('div');
time.className = 'event-time';
time.textContent = formatTime(e.datetime);
const streamers = document.createElement('div');
streamers.className = 'event-streamers';
streamers.textContent = e.streamers;
summary.appendChild(title);
summary.appendChild(time);
if (e.streamers) summary.appendChild(streamers);
item.appendChild(summary);
if (isExpandable) {
const details = document.createElement('div');
details.className = 'event-details';
details.innerHTML = `
<p><strong>Location:</strong> ${e.location || 'N/A'}</p>
<p><strong>Description:</strong> ${e.description || 'N/A'}</p>
`;
item.appendChild(details);
item.addEventListener('click', () => toggleExpanded(item));
item.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleExpanded(item);
}
});
}
container.appendChild(item);
});
}
function toggleExpanded(item) {
const expanded = item.classList.toggle('expanded');
item.setAttribute('aria-expanded', expanded);
}
// Fetch events by calling Apps Script backend
function fetchEvents() {
return new Promise((resolve, reject) => {
google.script.run.withSuccessHandler(resolve).withFailureHandler(reject).getEvents();
});
}
// Load events and render Today and Upcoming sections
function loadEvents() {
fetchEvents()
.then(data => {
const events = createEvents(data);
const now = new Date();
const todayEvents = events.filter(ev => isSameDay(ev.datetime, now));
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
const upcomingEvents = events.filter(ev => ev.datetime >= tomorrow);
renderEvents(document.getElementById('today-events'), todayEvents);
renderEvents(document.getElementById('upcoming-events'), upcomingEvents, false);
})
.catch(err => {
console.error('Error fetching events:', err);
document.getElementById('today-events').textContent = 'Failed to load events.';
document.getElementById('upcoming-events').textContent = 'Failed to load events.';
});
}
// Refresh events hourly on the hour
function scheduleHourlyRefresh() {
const now = new Date();
const msUntilNextHour = (60 - now.getMinutes()) * 60 * 1000 - now.getSeconds() * 1000 - now.getMilliseconds();
setTimeout(() => {
loadEvents();
setInterval(loadEvents, 60 * 60 * 1000);
}, msUntilNextHour);
}
window.onload = () => {
loadEvents();
scheduleHourlyRefresh();
};
</script>
</body>
</html>