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.
166 lines
5.6 KiB
JavaScript
166 lines
5.6 KiB
JavaScript
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);
|
|
});
|
|
}
|