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,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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user