Express + SQLite (node:sqlite, no native build step) family calendar: parent accounts with household invites, per-child calendars with save/duplicate/print, a token-gated read-only kiosk view for tablets, and polling to keep parent and kiosk views in sync. Defaults to port 3007.
152 lines
5.9 KiB
JavaScript
152 lines
5.9 KiB
JavaScript
const calendarId = new URLSearchParams(window.location.search).get('calendarId');
|
|
let calendar = null;
|
|
|
|
const weekdayBoard = document.getElementById('weekdayBoard');
|
|
const weekendWrap = document.getElementById('weekendWrap');
|
|
const legend = document.getElementById('legend');
|
|
const toast = document.getElementById('toast');
|
|
|
|
function showToast(msg) {
|
|
toast.textContent = msg;
|
|
toast.classList.add('show');
|
|
setTimeout(() => toast.classList.remove('show'), 1800);
|
|
}
|
|
|
|
function debounce(fn, ms) {
|
|
let t;
|
|
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
|
|
}
|
|
|
|
const handlers = {
|
|
onToggleDone: (taskId, done) => api.patch(`/api/calendars/${calendarId}/tasks/${taskId}`, { done }).catch(() => showToast('Could not save')),
|
|
onTextEdit: (taskId, text) => api.patch(`/api/calendars/${calendarId}/tasks/${taskId}`, { text }).catch(() => showToast('Could not save')),
|
|
onLabelEdit: (blockKey, label) => api.patch(`/api/calendars/${calendarId}/blocks/${blockKey}`, { label }).catch(() => showToast('Could not save')),
|
|
onAddTask: async (dayOfWeek, blockKey, text) => {
|
|
const { task } = await api.post(`/api/calendars/${calendarId}/tasks`, { dayOfWeek, blockKey, text });
|
|
return task;
|
|
},
|
|
onDeleteTask: (taskId) => api.del(`/api/calendars/${calendarId}/tasks/${taskId}`).catch(() => showToast('Could not delete')),
|
|
};
|
|
|
|
function todayName() {
|
|
return new Date().toLocaleDateString('en-US', { weekday: 'long' });
|
|
}
|
|
|
|
function draw() {
|
|
const childNameEl = document.getElementById('childName');
|
|
if (document.activeElement !== childNameEl) childNameEl.textContent = calendar.childName || '';
|
|
const titleEl = document.getElementById('calTitle');
|
|
if (document.activeElement !== titleEl) titleEl.textContent = calendar.title;
|
|
|
|
renderCalendar({
|
|
calendar,
|
|
editable: true,
|
|
weekdayBoard,
|
|
weekendWrap,
|
|
handlers,
|
|
todayName: todayName(),
|
|
});
|
|
renderLegend(legend, calendar);
|
|
|
|
weekendWrap.classList.toggle('hidden', !calendar.showWeekend);
|
|
document.getElementById('toggleWeekend').textContent = calendar.showWeekend ? 'Hide weekend' : 'Show weekend';
|
|
|
|
buildPeriodToggles();
|
|
}
|
|
|
|
function buildPeriodToggles() {
|
|
const el = document.getElementById('periodToggles');
|
|
el.querySelectorAll('label.opt').forEach((n) => n.remove());
|
|
calendar.blocks.forEach((block) => {
|
|
const label = document.createElement('label');
|
|
label.className = 'opt';
|
|
const cb = document.createElement('input');
|
|
cb.type = 'checkbox';
|
|
cb.checked = !document.body.classList.contains('hide-' + block.key);
|
|
cb.onchange = () => document.body.classList.toggle('hide-' + block.key, !cb.checked);
|
|
const span = document.createElement('span');
|
|
span.textContent = block.label;
|
|
label.appendChild(cb);
|
|
label.appendChild(span);
|
|
el.appendChild(label);
|
|
});
|
|
}
|
|
|
|
async function load() {
|
|
if (!calendarId) {
|
|
document.body.innerHTML = '<p style="padding:40px;">No calendar selected. <a href="/dashboard.html">Back to dashboard</a></p>';
|
|
return;
|
|
}
|
|
try {
|
|
const data = await api.get(`/api/calendars/${calendarId}`);
|
|
calendar = data.calendar;
|
|
} catch (err) {
|
|
if (err.status === 401) { window.location.href = '/login.html'; return; }
|
|
document.body.innerHTML = `<p style="padding:40px;">${err.message}. <a href="/dashboard.html">Back to dashboard</a></p>`;
|
|
return;
|
|
}
|
|
draw();
|
|
}
|
|
|
|
document.getElementById('toggleWeekend').addEventListener('click', async () => {
|
|
calendar.showWeekend = !calendar.showWeekend;
|
|
weekendWrap.classList.toggle('hidden', !calendar.showWeekend);
|
|
document.getElementById('toggleWeekend').textContent = calendar.showWeekend ? 'Hide weekend' : 'Show weekend';
|
|
await api.patch(`/api/calendars/${calendarId}`, { showWeekend: calendar.showWeekend });
|
|
});
|
|
|
|
document.getElementById('resetChecks').addEventListener('click', async () => {
|
|
const data = await api.post(`/api/calendars/${calendarId}/reset-checks`);
|
|
calendar = data.calendar;
|
|
draw();
|
|
showToast('Checkboxes reset');
|
|
});
|
|
|
|
document.getElementById('printBtn').addEventListener('click', () => window.print());
|
|
|
|
const titleEl = document.getElementById('calTitle');
|
|
titleEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); titleEl.blur(); } });
|
|
const debouncedTitleSave = debounce(() => {
|
|
if (titleEl.textContent.trim()) api.patch(`/api/calendars/${calendarId}`, { title: titleEl.textContent.trim() });
|
|
}, 500);
|
|
titleEl.addEventListener('input', debouncedTitleSave);
|
|
|
|
const childNameEl = document.getElementById('childName');
|
|
childNameEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); childNameEl.blur(); } });
|
|
const debouncedChildNameSave = debounce(() => {
|
|
if (childNameEl.textContent.trim()) api.patch(`/api/children/${calendar.childId}`, { name: childNameEl.textContent.trim() });
|
|
}, 500);
|
|
childNameEl.addEventListener('input', debouncedChildNameSave);
|
|
|
|
// Polling keeps this view in sync with kiosk checkbox taps (and the other
|
|
// parent's edits) without a manual reload. Paused while the parent is mid-edit
|
|
// in any contenteditable field, so an incoming poll can't yank their cursor.
|
|
let isEditingFocused = false;
|
|
document.addEventListener('focusin', (e) => {
|
|
if (e.target.isContentEditable) isEditingFocused = true;
|
|
});
|
|
document.addEventListener('focusout', (e) => {
|
|
if (e.target.isContentEditable) {
|
|
setTimeout(() => {
|
|
isEditingFocused = !!(document.activeElement && document.activeElement.isContentEditable);
|
|
}, 0);
|
|
}
|
|
});
|
|
|
|
async function poll() {
|
|
if (!calendar || isEditingFocused || document.hidden) return;
|
|
try {
|
|
const data = await api.get(`/api/calendars/${calendarId}/poll?since=${encodeURIComponent(calendar.updatedAt)}`);
|
|
if (data.changed) {
|
|
calendar = data.calendar;
|
|
draw();
|
|
}
|
|
} catch (err) {
|
|
if (err.status === 401) window.location.href = '/login.html';
|
|
}
|
|
}
|
|
setInterval(poll, 4000);
|
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) poll(); });
|
|
|
|
load();
|