Files
KCal/public/js/calendarRender.js
T
ort 1768ce7740 Add per-task timers with milestone audio for ADHD-friendly time awareness
Every task now has a duration (10 min default) and a child-initiated start:
a play icon on the kiosk, alongside the checkbox, begins a server-anchored
countdown for that specific task. Deliberately not a beep-every-minute
alarm -- audio is milestone-only (start/halfway/almost-done/time's-up),
since frequent interrupting alerts can backfire for ADHD kids rather than
help. Countdown is drift-free (recomputed each tick from the fixed
started_at anchor, never decremented), audio is synthesized client-side
via Web Audio (zero external assets, matching this app's existing
hand-rolled-PNG-icon philosophy), and the child's own start tap is what
unlocks audio playback for the rest of the session on iOS.

Two real bugs caught and fixed during design, before they shipped:
- Marking a task done now clears started_at (both the kiosk and parent
  routes) -- without this, unchecking a finished task later would
  resurrect a stale timer and instantly show "time's up" on a task the
  child hasn't touched today.
- The parent editor's poll-pause guard only covered contenteditable
  fields; a plain number input for duration would have been silently
  unprotected from being clobbered by an incoming 4s poll mid-edit.
  Extended the guard to cover it, verified live that the DOM and focus
  both survive a poll while the field is focused.

Schema: calendar_tasks gains duration_minutes (NOT NULL DEFAULT 10) and
started_at (nullable). Verified against a simulated copy of the real
production schema/data that the static DEFAULT backfills every existing
row automatically (no business-logic backfill needed, unlike is_admin),
and that repeated boots stay idempotent. Duplicate-calendar carries
durations forward but always resets started_at, verified end-to-end.

Kiosk countdown/milestone-firing verified live in-browser end-to-end: real
timer start, halfway/almost-done/done firing at the correct proportional
thresholds (not fixed minutes, so it scales from ~1min to 20+min tasks),
and a mid-countdown page reload resuming at the correct remaining time
with already-passed milestones backfilled silently rather than replayed.
2026-08-16 11:29:22 -04:00

230 lines
7.9 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; });
// Kiosk-only: tasks with a running timer, for kiosk.js's tick loop to
// update in place every 250ms without a full re-render.
const activeTimers = [];
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);
let startBtn = null;
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 or
// the start-timer button — those handle themselves.
row.addEventListener('click', (e) => {
if (e.target === cb || e.target === startBtn) return;
cb.checked = !cb.checked;
cb.dispatchEvent(new Event('change'));
});
if (!task.done) {
startBtn = document.createElement('button');
startBtn.className = 'timer-start';
startBtn.textContent = '▶';
startBtn.setAttribute('aria-label', 'Start timer');
startBtn.onclick = () => handlers.onStartTimer(task.id);
row.appendChild(startBtn);
}
if (task.startedAt && !task.done) {
const timer = document.createElement('div');
timer.className = 'task-timer';
const track = document.createElement('div');
track.className = 'timer-track';
const fill = document.createElement('div');
fill.className = 'timer-fill';
track.appendChild(fill);
const label = document.createElement('span');
label.className = 'timer-label';
timer.appendChild(track);
timer.appendChild(label);
row.appendChild(timer);
activeTimers.push({
taskId: task.id,
startedAt: task.startedAt,
durationMinutes: task.durationMinutes,
fillEl: fill,
labelEl: label,
});
}
}
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 durationInput = document.createElement('input');
durationInput.type = 'number';
durationInput.className = 'duration-input';
durationInput.min = '1';
durationInput.max = '180';
durationInput.value = task.durationMinutes;
durationInput.title = 'Minutes';
const debouncedDuration = debounce(() => {
const value = parseInt(durationInput.value, 10);
if (Number.isInteger(value) && value >= 1 && value <= 180) {
handlers.onDurationEdit(task.id, value);
}
}, 500);
durationInput.addEventListener('input', debouncedDuration);
row.appendChild(durationInput);
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, activeTimers };
}
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);
});
}