Add self-hosted kids calendar app
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.
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user