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:
ort
2026-08-15 13:48:01 -04:00
commit 66b8fc5d4d
42 changed files with 3567 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
const api = {
async request(method, path, body) {
const res = await fetch(path, {
method,
credentials: 'include',
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (res.status === 204) return null;
let data = null;
const text = await res.text();
if (text) {
try { data = JSON.parse(text); } catch { data = null; }
}
if (!res.ok) {
const err = new Error((data && data.error) || `Request failed (${res.status})`);
err.status = res.status;
throw err;
}
return data;
},
get(path) { return this.request('GET', path); },
post(path, body) { return this.request('POST', path, body === undefined ? {} : body); },
patch(path, body) { return this.request('PATCH', path, body === undefined ? {} : body); },
del(path) { return this.request('DELETE', path); },
};
+151
View File
@@ -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();
+165
View File
@@ -0,0 +1,165 @@
const BLOCK_ICONS = { morning: '🌅', school: '🎒', after: '⚽', evening: '🌙' };
const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'];
const WEEKEND = ['Saturday', 'Sunday'];
function debounce(fn, ms) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), ms);
};
}
// Renders the weekly board. `editable` gates every mutation affordance
// (contenteditable, delete buttons, add-task buttons) — the kiosk view passes
// editable:false and gets a board with nothing but working checkboxes.
function renderCalendar({ calendar, editable, weekdayBoard, weekendWrap, handlers, todayName }) {
weekdayBoard.innerHTML = '';
weekendWrap.innerHTML = '';
const currentLabels = {};
calendar.blocks.forEach((b) => { currentLabels[b.key] = b.label; });
function syncLabel(key, sourceEl) {
document.querySelectorAll(`.label[data-block="${key}"]`).forEach((el) => {
if (el !== sourceEl) el.textContent = sourceEl.textContent;
});
document.querySelectorAll(`.legend-label[data-block="${key}"]`).forEach((el) => {
el.textContent = sourceEl.textContent;
});
}
function makeTask(dayName, blockKey, task) {
const row = document.createElement('div');
row.className = 'task' + (task.done ? ' done' : '') + (editable ? '' : ' kiosk');
row.dataset.taskId = task.id;
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = task.done;
cb.onchange = () => {
row.classList.toggle('done', cb.checked);
handlers.onToggleDone(task.id, cb.checked);
};
const span = document.createElement('div');
span.className = 'task-text';
span.textContent = task.text;
row.appendChild(cb);
row.appendChild(span);
if (editable) {
span.contentEditable = 'true';
span.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); span.blur(); }
});
const debouncedEdit = debounce(() => {
if (span.textContent.trim()) handlers.onTextEdit(task.id, span.textContent.trim());
}, 500);
span.addEventListener('input', debouncedEdit);
span.addEventListener('blur', () => {
if (!span.textContent.trim()) span.textContent = task.text;
});
const del = document.createElement('button');
del.className = 'del';
del.textContent = '✕';
del.onclick = () => {
row.remove();
handlers.onDeleteTask(task.id);
};
row.appendChild(del);
}
return row;
}
function makeDayCard(dayName) {
const card = document.createElement('div');
card.className = 'day-card' + (dayName === todayName ? ' today' : '');
const head = document.createElement('div');
head.className = 'day-head';
head.innerHTML = `<div class="day-name">${dayName}</div>` +
(dayName === todayName ? `<div class="today-badge">TODAY</div>` : '');
card.appendChild(head);
calendar.blocks.forEach((block) => {
const blockEl = document.createElement('div');
blockEl.className = 'block ' + block.key;
const title = document.createElement('div');
title.className = 'block-title';
const iconSpan = document.createElement('span');
iconSpan.className = 'icon';
iconSpan.textContent = BLOCK_ICONS[block.key] || '';
const labelSpan = document.createElement('span');
labelSpan.className = 'label';
labelSpan.dataset.block = block.key;
labelSpan.textContent = currentLabels[block.key];
if (editable) {
labelSpan.contentEditable = 'true';
labelSpan.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); labelSpan.blur(); }
});
const debouncedLabel = debounce(() => {
if (labelSpan.textContent.trim()) {
currentLabels[block.key] = labelSpan.textContent.trim();
handlers.onLabelEdit(block.key, labelSpan.textContent.trim());
}
}, 500);
labelSpan.addEventListener('input', () => { syncLabel(block.key, labelSpan); debouncedLabel(); });
}
title.appendChild(iconSpan);
title.appendChild(labelSpan);
blockEl.appendChild(title);
const list = document.createElement('div');
list.className = 'task-list';
(calendar.days[dayName][block.key] || []).forEach((task) => {
list.appendChild(makeTask(dayName, block.key, task));
});
blockEl.appendChild(list);
if (editable) {
const addBtn = document.createElement('button');
addBtn.className = 'add-task';
addBtn.textContent = '+ add task';
addBtn.onclick = async () => {
const newTask = await handlers.onAddTask(dayName, block.key, 'New task');
const row = makeTask(dayName, block.key, newTask);
list.appendChild(row);
const span = row.querySelector('.task-text');
span.focus();
document.execCommand('selectAll', false, null);
};
blockEl.appendChild(addBtn);
}
card.appendChild(blockEl);
});
return card;
}
WEEKDAYS.forEach((d) => weekdayBoard.appendChild(makeDayCard(d)));
WEEKEND.forEach((d) => weekendWrap.appendChild(makeDayCard(d)));
return { currentLabels };
}
function renderLegend(legendEl, calendar) {
legendEl.innerHTML = '';
calendar.blocks.forEach((block) => {
const item = document.createElement('span');
item.className = 'item';
item.innerHTML = `<span class="dot" style="background:var(--${block.key})"></span>` +
`<span class="legend-label" data-block="${block.key}">${block.label}</span>`;
legendEl.appendChild(item);
});
}
+213
View File
@@ -0,0 +1,213 @@
let children = [];
function showToast(msg) {
const toast = document.getElementById('toast');
toast.textContent = msg;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 2200);
}
function fmtDate(iso) {
if (!iso) return '';
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
async function boot() {
try {
const me = await api.get('/api/auth/me');
document.getElementById('householdName').textContent = me.household.name;
document.getElementById('whoami').textContent = `Logged in as ${me.parent.name} (${me.parent.email})`;
} catch {
window.location.href = '/login.html';
return;
}
await Promise.all([loadParents(), loadChildren()]);
}
async function loadParents() {
const { parents } = await api.get('/api/household');
const el = document.getElementById('parentsList');
el.innerHTML = '';
parents.forEach((p) => {
const card = document.createElement('div');
card.className = 'entity-card';
card.innerHTML = `<h3>${escapeHtml(p.name)}</h3><div class="meta">${escapeHtml(p.email)}</div>`;
el.appendChild(card);
});
}
async function loadChildren() {
const data = await api.get('/api/children');
children = data.children;
const el = document.getElementById('childrenList');
el.innerHTML = '';
if (children.length === 0) {
el.innerHTML = '<div class="empty-state">No children yet — add one to create their first calendar.</div>';
return;
}
for (const child of children) {
const card = await renderChildCard(child);
el.appendChild(card);
}
}
async function renderChildCard(child) {
const { calendars } = await api.get(`/api/children/${child.id}/calendars`);
const card = document.createElement('div');
card.className = 'entity-card';
const calendarsHtml = calendars.length
? calendars.map((c) => calendarRowHtml(child, c)).join('')
: '<div class="meta">No calendars yet.</div>';
card.innerHTML = `
<h3>${escapeHtml(child.name)}</h3>
<div class="meta">${calendars.length} calendar${calendars.length === 1 ? '' : 's'}</div>
<div>${calendarsHtml}</div>
<div class="row">
<button class="tool primary" data-action="new-cal" data-child="${child.id}">+ New calendar</button>
<button class="tool" data-action="kiosk-link" data-child="${child.id}">Tablet link</button>
<button class="tool danger" data-action="delete-child" data-child="${child.id}">Delete child</button>
</div>
`;
card.querySelectorAll('[data-action]').forEach((btn) => {
btn.addEventListener('click', () => handleCardAction(btn.dataset.action, btn.dataset));
});
return card;
}
function calendarRowHtml(child, cal) {
const isActive = child.activeCalendarId === cal.id;
return `
<div class="row" style="justify-content:space-between; align-items:center; padding:6px 0; border-top:1px solid var(--line);">
<div>
<strong>${escapeHtml(cal.title)}</strong>
<div class="meta">Updated ${fmtDate(cal.updated_at)}${isActive ? ' · on tablet' : ''}</div>
</div>
<div class="row">
<a class="tool" href="/calendar.html?calendarId=${cal.id}">Open</a>
<button class="tool" data-action="duplicate-cal" data-child="${child.id}" data-cal="${cal.id}" data-title="${escapeHtml(cal.title)}">Duplicate</button>
${isActive ? '' : `<button class="tool" data-action="set-active" data-child="${child.id}" data-cal="${cal.id}">Set on tablet</button>`}
<button class="tool danger" data-action="delete-cal" data-child="${child.id}" data-cal="${cal.id}">Delete</button>
</div>
</div>
`;
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
async function handleCardAction(action, ds) {
if (action === 'new-cal') {
document.getElementById('newCalChildId').value = ds.child;
document.getElementById('newCalTitle').value = '';
document.getElementById('newCalDuplicate').checked = false;
openModal('newCalendarModal');
} else if (action === 'duplicate-cal') {
const title = prompt('Title for the duplicated calendar:', `Copy of ${ds.title}`);
if (!title) return;
await api.post(`/api/children/${ds.child}/calendars`, { title, duplicateFromCalendarId: Number(ds.cal) });
showToast('Calendar duplicated');
await loadChildren();
} else if (action === 'set-active') {
await api.patch(`/api/children/${ds.child}`, { activeCalendarId: Number(ds.cal) });
showToast('Tablet will now show this calendar');
await loadChildren();
} else if (action === 'delete-cal') {
if (!confirm('Delete this calendar? This cannot be undone.')) return;
await api.del(`/api/calendars/${ds.cal}`);
showToast('Calendar deleted');
await loadChildren();
} else if (action === 'kiosk-link') {
const child = children.find((c) => c.id === Number(ds.child));
openKioskModal(child);
} else if (action === 'delete-child') {
if (!confirm('Delete this child and all their calendars? This cannot be undone.')) return;
await api.del(`/api/children/${ds.child}`);
showToast('Child deleted');
await loadChildren();
}
}
function openModal(id) { document.getElementById(id).classList.remove('hidden'); }
function closeModal(id) { document.getElementById(id).classList.add('hidden'); }
document.getElementById('addChildBtn').addEventListener('click', () => {
document.getElementById('childNameInput').value = '';
openModal('addChildModal');
});
document.getElementById('cancelAddChildBtn').addEventListener('click', () => closeModal('addChildModal'));
document.getElementById('addChildForm').addEventListener('submit', async (e) => {
e.preventDefault();
await api.post('/api/children', { name: document.getElementById('childNameInput').value });
closeModal('addChildModal');
showToast('Child added');
await loadChildren();
});
document.getElementById('cancelNewCalBtn').addEventListener('click', () => closeModal('newCalendarModal'));
document.getElementById('newCalendarForm').addEventListener('submit', async (e) => {
e.preventDefault();
const childId = document.getElementById('newCalChildId').value;
const title = document.getElementById('newCalTitle').value;
const duplicate = document.getElementById('newCalDuplicate').checked;
let body = { title };
if (duplicate) {
const { calendars } = await api.get(`/api/children/${childId}/calendars`);
if (calendars.length) body.duplicateFromCalendarId = calendars[0].id;
}
const { calendar } = await api.post(`/api/children/${childId}/calendars`, body);
closeModal('newCalendarModal');
window.location.href = `/calendar.html?calendarId=${calendar.id}`;
});
document.getElementById('inviteBtn').addEventListener('click', async () => {
const { invite } = await api.post('/api/household/invites');
const link = `${window.location.origin}${invite.joinPath}`;
document.getElementById('inviteLinkField').value = link;
openModal('inviteModal');
});
document.getElementById('closeInviteBtn').addEventListener('click', () => closeModal('inviteModal'));
document.getElementById('copyInviteBtn').addEventListener('click', () => {
document.getElementById('inviteLinkField').select();
navigator.clipboard.writeText(document.getElementById('inviteLinkField').value);
showToast('Link copied');
});
let kioskModalChild = null;
function openKioskModal(child) {
kioskModalChild = child;
document.getElementById('kioskChildName').textContent = child.name;
document.getElementById('kioskLinkField').value = `${window.location.origin}${child.kioskPath}`;
openModal('kioskModal');
}
document.getElementById('closeKioskBtn').addEventListener('click', () => closeModal('kioskModal'));
document.getElementById('copyKioskBtn').addEventListener('click', () => {
document.getElementById('kioskLinkField').select();
navigator.clipboard.writeText(document.getElementById('kioskLinkField').value);
showToast('Link copied');
});
document.getElementById('regenKioskBtn').addEventListener('click', async () => {
if (!confirm('Regenerate this tablet link? The old link will stop working.')) return;
const data = await api.post(`/api/children/${kioskModalChild.id}/kiosk-token/regenerate`);
document.getElementById('kioskLinkField').value = `${window.location.origin}${data.kioskPath}`;
showToast('Link regenerated');
await loadChildren();
});
document.getElementById('logoutBtn').addEventListener('click', async () => {
await api.post('/api/auth/logout');
window.location.href = '/login.html';
});
boot();
+59
View File
@@ -0,0 +1,59 @@
const token = window.location.pathname.split('/').filter(Boolean).pop();
const weekdayBoard = document.getElementById('weekdayBoard');
const weekendWrap = document.getElementById('weekendWrap');
async function apiGet(path) {
const res = await fetch(path, { credentials: 'omit' });
if (!res.ok) throw new Error('request failed');
return res.json();
}
async function apiPatch(path, body) {
const res = await fetch(path, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error('request failed');
return res.json();
}
const handlers = {
onToggleDone: (taskId, done) => apiPatch(`/api/kiosk/${token}/tasks/${taskId}`, { done }).catch(() => {}),
};
function todayName() {
return new Date().toLocaleDateString('en-US', { weekday: 'long' });
}
function draw(calendar) {
document.getElementById('childName').textContent = calendar.childName || '';
document.getElementById('calTitle').textContent = calendar.title || '';
renderCalendar({
calendar,
editable: false,
weekdayBoard,
weekendWrap,
handlers,
todayName: todayName(),
});
}
async function poll() {
try {
const data = await apiGet(`/api/kiosk/${token}/calendar`);
if (!data.calendar) {
document.getElementById('emptyState').style.display = 'block';
document.getElementById('childName').textContent = data.child ? data.child.name : '';
return;
}
document.getElementById('emptyState').style.display = 'none';
draw(data.calendar);
} catch {
// transient network error — try again next tick
}
}
poll();
setInterval(poll, 4000);