Files
Calender/static/js/app.js
2026-07-28 21:55:40 +12:00

343 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}
}