343 lines
12 KiB
JavaScript
343 lines
12 KiB
JavaScript
const INTERVALS = {
|
||
TIME: 1000,
|
||
WEATHER: 900000,
|
||
CALENDAR: 300000
|
||
};
|
||
|
||
const INACTIVITY_TIMEOUT = 20 * 60 * 1000; // 20 minutes
|
||
const SLIDE_DURATION = 15 * 1000; // 15 seconds per photo
|
||
|
||
const DAY_LABELS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||
const MONTH_LABELS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||
|
||
let activeFilter = 'all';
|
||
let filterOptionsBuilt = false;
|
||
let lastData = null;
|
||
let lastWeatherData = null;
|
||
|
||
// Slideshow state
|
||
let photos = [];
|
||
let slideIndex = 0;
|
||
let activeSlot = 'a';
|
||
let slideTimer = null;
|
||
let inactivityTimer = null;
|
||
let slideshowActive = false;
|
||
|
||
function weatherIcon(code) {
|
||
return `<img src="https://openweathermap.org/img/wn/${code}@2x.png" alt="${code}" class="owm-icon">`;
|
||
}
|
||
|
||
function toISODate(date) {
|
||
const y = date.getFullYear();
|
||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||
const d = String(date.getDate()).padStart(2, '0');
|
||
return `${y}-${m}-${d}`;
|
||
}
|
||
|
||
function formatRangeLabel(startISO, endISO) {
|
||
const start = new Date(startISO + 'T00:00:00');
|
||
const end = new Date(endISO + 'T00:00:00');
|
||
const startLabel = `${MONTH_LABELS[start.getMonth()]} ${start.getDate()}`;
|
||
const endLabel = end.getMonth() === start.getMonth()
|
||
? `${end.getDate()}`
|
||
: `${MONTH_LABELS[end.getMonth()]} ${end.getDate()}`;
|
||
return `${startLabel} – ${endLabel}`;
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
updateTime();
|
||
updateWeather();
|
||
fetchCalendar();
|
||
initSlideshow();
|
||
|
||
document.getElementById('member-filter').addEventListener('change', (e) => {
|
||
activeFilter = e.target.value;
|
||
if (lastData) renderDays(lastData);
|
||
});
|
||
document.getElementById('add-btn').addEventListener('click', showAddToast);
|
||
|
||
setInterval(updateTime, INTERVALS.TIME);
|
||
setInterval(updateWeather, INTERVALS.WEATHER);
|
||
setInterval(fetchCalendar, INTERVALS.CALENDAR);
|
||
});
|
||
|
||
function updateTime() {
|
||
const now = new Date();
|
||
const hours = String(now.getHours()).padStart(2, '0');
|
||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||
const el = document.getElementById('info-clock');
|
||
if (el) el.textContent = `${hours}:${minutes}`;
|
||
}
|
||
|
||
async function updateWeather() {
|
||
try {
|
||
const response = await fetch('/api/weather');
|
||
if (!response.ok) throw new Error('Weather fetch failed');
|
||
const data = await response.json();
|
||
if (data.error || !data.current) return;
|
||
lastWeatherData = data;
|
||
renderInfoWeather(data);
|
||
} catch (error) {
|
||
console.error('Error updating weather:', error);
|
||
}
|
||
}
|
||
|
||
function renderInfoWeather(data) {
|
||
const tempEl = document.getElementById('info-current-temp');
|
||
const iconEl = document.getElementById('info-weather-icon');
|
||
const descEl = document.getElementById('info-current-desc');
|
||
const forecastEl = document.getElementById('info-forecast');
|
||
if (!tempEl) return;
|
||
|
||
tempEl.textContent = `${data.current.temp}°`;
|
||
iconEl.innerHTML = weatherIcon(data.current.icon);
|
||
descEl.textContent = data.current.description;
|
||
|
||
forecastEl.innerHTML = (data.forecast || []).map(day => `
|
||
<div class="forecast-row">
|
||
<span class="f-day">${day.date}</span>
|
||
<span class="f-icon">${weatherIcon(day.icon)}</span>
|
||
<span class="f-temps">
|
||
<span class="f-max">${day.temp_max}°</span>
|
||
<span class="f-min"> / ${day.temp_min}°</span>
|
||
</span>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
async function fetchCalendar() {
|
||
try {
|
||
const response = await fetch(`/api/calendar?start=${toISODate(new Date())}`);
|
||
if (!response.ok) throw new Error('Calendar fetch failed');
|
||
|
||
const data = await response.json();
|
||
lastData = data;
|
||
|
||
document.getElementById('date-range').textContent = formatRangeLabel(data.start, data.end);
|
||
buildFilterOptions(data.members);
|
||
renderDays(data);
|
||
} catch (error) {
|
||
console.error('Error updating calendar:', error);
|
||
document.getElementById('week-grid').innerHTML = '<div class="event-placeholder">Unable to load calendar</div>';
|
||
}
|
||
}
|
||
|
||
function buildFilterOptions(members) {
|
||
if (filterOptionsBuilt || !members) return;
|
||
const select = document.getElementById('member-filter');
|
||
members.forEach(member => {
|
||
const option = document.createElement('option');
|
||
option.value = member.key;
|
||
option.textContent = member.name;
|
||
select.appendChild(option);
|
||
});
|
||
filterOptionsBuilt = true;
|
||
}
|
||
|
||
function filterEvents(events) {
|
||
if (activeFilter === 'all') return events;
|
||
return events.filter(e => e.member === activeFilter);
|
||
}
|
||
|
||
function formatEventTime(event) {
|
||
const start = new Date(event.start);
|
||
const end = new Date(event.end);
|
||
const isAllDay = start.getHours() === 0 && start.getMinutes() === 0 && end.getHours() === 0 && end.getMinutes() === 0;
|
||
if (isAllDay) return 'All day';
|
||
return start.toLocaleTimeString('en-NZ', { hour: '2-digit', minute: '2-digit' });
|
||
}
|
||
|
||
function eventCardHTML(event) {
|
||
return `
|
||
<div class="event-card" style="background:${event.bg}; border-left-color:${event.color};">
|
||
<div class="event-time">${formatEventTime(event)}</div>
|
||
<div class="event-title">${event.title}</div>
|
||
<div class="event-member">${event.member_name}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Grid positions for 6 days arranged 3-per-row, info panel in col 4 spanning both rows
|
||
const DAY_GRID_POSITIONS = [
|
||
{col: 1, row: 1}, {col: 2, row: 1}, {col: 3, row: 1},
|
||
{col: 1, row: 2}, {col: 2, row: 2}, {col: 3, row: 2},
|
||
];
|
||
|
||
function renderDays(data) {
|
||
const grid = document.getElementById('week-grid');
|
||
grid.innerHTML = '';
|
||
|
||
const todayISO = toISODate(new Date());
|
||
const now = new Date();
|
||
|
||
data.days.forEach((day, i) => {
|
||
const dayDate = new Date(day.date + 'T00:00:00');
|
||
const events = filterEvents(day.events);
|
||
const pos = DAY_GRID_POSITIONS[i];
|
||
|
||
const column = document.createElement('div');
|
||
column.className = 'day-column' + (day.date === todayISO ? ' is-today' : '');
|
||
column.style.gridColumn = pos.col;
|
||
column.style.gridRow = pos.row;
|
||
column.innerHTML = `
|
||
<div class="day-header">
|
||
<div class="day-name">${DAY_LABELS[dayDate.getDay()]}</div>
|
||
<div class="day-number">${dayDate.getDate()}</div>
|
||
<div class="day-count">${events.length} event${events.length === 1 ? '' : 's'}</div>
|
||
</div>
|
||
<div class="day-events">
|
||
${events.map(eventCardHTML).join('')}
|
||
</div>
|
||
`;
|
||
grid.appendChild(column);
|
||
});
|
||
|
||
// Info panel — clock + date + weather
|
||
const hours = String(now.getHours()).padStart(2, '0');
|
||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||
const panel = document.createElement('div');
|
||
panel.className = 'info-panel';
|
||
panel.style.gridColumn = '4';
|
||
panel.style.gridRow = '1 / 3';
|
||
panel.innerHTML = `
|
||
<div id="info-clock" class="info-clock">${hours}:${minutes}</div>
|
||
<div class="info-weekday">${DAY_LABELS[now.getDay()]}</div>
|
||
<div class="info-date">${now.getDate()} ${MONTH_LABELS[now.getMonth()]} ${now.getFullYear()}</div>
|
||
<div class="info-divider"></div>
|
||
<div class="info-weather-current">
|
||
<span id="info-weather-icon">--</span>
|
||
<div>
|
||
<div id="info-current-temp" class="info-current-temp">--°</div>
|
||
<div id="info-current-desc" class="info-current-desc">--</div>
|
||
</div>
|
||
</div>
|
||
<div id="info-forecast" class="info-forecast"></div>
|
||
`;
|
||
grid.appendChild(panel);
|
||
|
||
// Populate weather immediately if we already have it
|
||
if (lastWeatherData) renderInfoWeather(lastWeatherData);
|
||
}
|
||
|
||
function showAddToast() {
|
||
let toast = document.querySelector('.add-toast');
|
||
if (!toast) {
|
||
toast = document.createElement('div');
|
||
toast.className = 'add-toast';
|
||
toast.textContent = 'This display is read-only — add events directly in Google Calendar.';
|
||
document.body.appendChild(toast);
|
||
}
|
||
toast.classList.add('visible');
|
||
clearTimeout(showAddToast._timer);
|
||
showAddToast._timer = setTimeout(() => toast.classList.remove('visible'), 3000);
|
||
}
|
||
|
||
// ---------- Slideshow screensaver ----------
|
||
|
||
function initSlideshow() {
|
||
['touchstart', 'click', 'mousemove', 'keydown'].forEach(evt => {
|
||
document.addEventListener(evt, handleUserActivity, { passive: true });
|
||
});
|
||
resetInactivityTimer();
|
||
}
|
||
|
||
function handleUserActivity() {
|
||
if (slideshowActive) {
|
||
hideSlideshow();
|
||
} else {
|
||
resetInactivityTimer();
|
||
}
|
||
}
|
||
|
||
function resetInactivityTimer() {
|
||
clearTimeout(inactivityTimer);
|
||
inactivityTimer = setTimeout(showSlideshow, INACTIVITY_TIMEOUT);
|
||
}
|
||
|
||
async function showSlideshow() {
|
||
// Fetch fresh each time so newly added photos appear without a restart
|
||
try {
|
||
const res = await fetch('/api/photos');
|
||
photos = await res.json();
|
||
} catch (e) {
|
||
console.error('Could not load photos:', e);
|
||
}
|
||
|
||
if (!photos.length) {
|
||
resetInactivityTimer();
|
||
return;
|
||
}
|
||
|
||
slideshowActive = true;
|
||
const overlay = document.getElementById('slideshow-overlay');
|
||
overlay.classList.remove('hidden');
|
||
|
||
slideIndex = Math.floor(Math.random() * photos.length);
|
||
activeSlot = 'a';
|
||
|
||
loadSlide('a', photos[slideIndex]);
|
||
if (photos.length > 1) {
|
||
loadSlide('b', photos[(slideIndex + 1) % photos.length]);
|
||
}
|
||
|
||
slideTimer = setInterval(advanceSlide, SLIDE_DURATION);
|
||
}
|
||
|
||
function hideSlideshow() {
|
||
slideshowActive = false;
|
||
clearInterval(slideTimer);
|
||
slideTimer = null;
|
||
|
||
const overlay = document.getElementById('slideshow-overlay');
|
||
overlay.classList.add('hidden');
|
||
|
||
// Clear images to free memory
|
||
document.getElementById('slide-a').classList.remove('active');
|
||
document.getElementById('slide-b').classList.remove('active');
|
||
document.getElementById('slide-a').src = '';
|
||
document.getElementById('slide-b').src = '';
|
||
|
||
resetInactivityTimer();
|
||
}
|
||
|
||
function loadSlide(slot, url) {
|
||
const img = document.getElementById(`slide-${slot}`);
|
||
if (slot === activeSlot) {
|
||
// Only fade in once the image is fully decoded — no blank-then-flash
|
||
img.onload = () => { img.onload = null; img.classList.add('active'); };
|
||
img.src = url;
|
||
// Handle cached images that won't fire onload
|
||
if (img.complete && img.naturalWidth > 0) {
|
||
img.onload = null;
|
||
img.classList.add('active');
|
||
}
|
||
} else {
|
||
img.src = url; // preload only
|
||
}
|
||
}
|
||
|
||
function advanceSlide() {
|
||
const nextSlot = activeSlot === 'a' ? 'b' : 'a';
|
||
const current = document.getElementById(`slide-${activeSlot}`);
|
||
const next = document.getElementById(`slide-${nextSlot}`);
|
||
|
||
const doSwap = () => {
|
||
next.onload = null;
|
||
next.classList.add('active');
|
||
current.classList.remove('active');
|
||
activeSlot = nextSlot;
|
||
slideIndex = (slideIndex + 1) % photos.length;
|
||
const preloadSrc = photos[(slideIndex + 1) % photos.length];
|
||
// Only change src after the fade-out completes — changing it mid-transition
|
||
// causes the new image to appear at partial opacity (the flash the user sees)
|
||
current.addEventListener('transitionend', () => { current.src = preloadSrc; }, { once: true });
|
||
};
|
||
|
||
// If the preloaded image is ready, swap immediately; otherwise wait for it
|
||
if (next.complete && next.naturalWidth > 0) {
|
||
doSwap();
|
||
} else {
|
||
next.onload = doSwap;
|
||
}
|
||
}
|