const INTERVALS = {
TIME: 1000,
WEATHER: 900000,
CALENDAR: 300000
};
const INACTIVITY_TIMEOUT = 60 * 60 * 1000; // 1 hour
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 `
`;
}
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 => `
${day.date}
${weatherIcon(day.icon)}
${day.temp_max}°
/ ${day.temp_min}°
`).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 = 'Unable to load calendar
';
}
}
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 `
${formatEventTime(event)}
${event.title}
${event.member_name}
`;
}
function renderDays(data) {
const grid = document.getElementById('week-grid');
grid.innerHTML = '';
const todayISO = toISODate(new Date());
const now = new Date();
data.days.forEach(day => {
const dayDate = new Date(day.date + 'T00:00:00');
const events = filterEvents(day.events);
const column = document.createElement('div');
column.className = 'day-column' + (day.date === todayISO ? ' is-today' : '');
column.innerHTML = `
${events.map(eventCardHTML).join('')}
`;
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.innerHTML = `
${hours}:${minutes}
${DAY_LABELS[now.getDay()]}
${now.getDate()} ${MONTH_LABELS[now.getMonth()]} ${now.getFullYear()}
`;
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 ----------
async function initSlideshow() {
try {
const res = await fetch('/api/photos');
photos = await res.json();
} catch (e) {
console.error('Could not load photos:', e);
}
// Any interaction resets the inactivity timer (or dismisses the slideshow)
['touchstart', 'click', 'mousemove', 'keydown'].forEach(evt => {
document.addEventListener(evt, handleUserActivity, { passive: true });
});
resetInactivityTimer();
}
function handleUserActivity() {
if (slideshowActive) {
hideSlideshow();
} else {
resetInactivityTimer();
}
}
function resetInactivityTimer() {
clearTimeout(inactivityTimer);
if (photos.length > 0) {
inactivityTimer = setTimeout(showSlideshow, INACTIVITY_TIMEOUT);
}
}
function showSlideshow() {
slideshowActive = true;
const overlay = document.getElementById('slideshow-overlay');
overlay.classList.remove('hidden');
// Start from a random position in the shuffled list
slideIndex = Math.floor(Math.random() * photos.length);
activeSlot = 'a';
// Show first photo immediately; preload second into the inactive slot
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}`);
img.src = url;
if (slot === activeSlot) {
// Slight delay lets the browser decode before fading in
requestAnimationFrame(() => requestAnimationFrame(() => img.classList.add('active')));
}
}
function advanceSlide() {
const nextSlot = activeSlot === 'a' ? 'b' : 'a';
const current = document.getElementById(`slide-${activeSlot}`);
const next = document.getElementById(`slide-${nextSlot}`);
// Fade in the preloaded next image, fade out current
next.classList.add('active');
current.classList.remove('active');
activeSlot = nextSlot;
// Preload the one after next into the now-hidden slot
slideIndex = (slideIndex + 1) % photos.length;
const upcoming = photos[(slideIndex + 1) % photos.length];
current.src = upcoming;
}