Parents get a real push notification when a kid checks off a task (false->true transitions only, fire-and-forget, degrades gracefully with no VAPID keys configured). Dashboard is a fully installable iOS/Android PWA; each child's kiosk link gets its own dynamic per-token manifest so "Add to Home Screen" opens straight into their board in standalone mode. Kiosk view is reworked for tablets: safe-area-aware full-bleed layout, the whole task row is now tappable (previously only the 24px checkbox was, well under Apple's touch-target minimum), and app icons are generated by a small dependency-free PNG encoder (no image tooling available in this environment). Push requires real HTTPS (iOS Safari won't allow it otherwise) - README and UNRAID.md cover VAPID setup and the HTTPS prerequisite.
177 lines
6.0 KiB
JavaScript
177 lines
6.0 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) {
|
|
// Kiosk mode: the 24px checkbox alone is a poor touch target, so the
|
|
// whole row toggles it. Skip when the tap landed on the checkbox
|
|
// itself — it already handles its own toggle+change natively.
|
|
row.addEventListener('click', (e) => {
|
|
if (e.target === cb) return;
|
|
cb.checked = !cb.checked;
|
|
cb.dispatchEvent(new Event('change'));
|
|
});
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|