Bind the calendar's pointer handlers once, not once per render

A QA pass over my own changes found a real defect. attachGridInteractions ran
on every calendar render, but #calendar is the same element throughout — only
its children are replaced — so each render stacked another full set of pointer
handlers on it. Five weeks of navigation left five, which meant five ghost
blocks during a drag, five context menus on a right-click, and five PUT
requests on a single drop. Verified by counting listeners through the debugger:
five sets after five renders, one after this change.

Also guards the drag-to-create path. It reuses the Add Schedule button's own
handler so the dialog resets exactly as it does for a normal create, but it
called .onclick() unguarded — and a drag is a user gesture that must never
throw. A missing button now quietly does nothing instead of raising an uncaught
error in the middle of an interaction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
This commit is contained in:
ScreenTinker 2026-07-28 20:08:31 -05:00
parent 7747d7e051
commit 5c95070d3a

View file

@ -434,6 +434,11 @@ export async function render(container) {
function clearGhost() { if (ghostEl && ghostEl.parentNode) ghostEl.parentNode.removeChild(ghostEl); }
function attachGridInteractions(cal) {
// #calendar is the SAME element on every render — only its children are replaced — so
// binding here per render stacked a full set of pointer handlers each time. Five weeks of
// navigation meant five ghosts on a drag and five PUTs on drop. Bind once.
if (cal.dataset.interactionsBound) return;
cal.dataset.interactionsBound = '1';
cal.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return; // left button only; right opens the menu
const block = e.target.closest('[data-sched-id]');
@ -640,10 +645,18 @@ export async function render(container) {
// Open the dialog already filled in with the slot that was drawn, so the gesture supplies the
// times and the dialog only has to supply what it alone knows (which playlist, which target).
function openCreateAt(dayDate, startMin, endMin) {
document.getElementById('addScheduleBtn').onclick();
// Reuses the Add Schedule button's own handler so the dialog resets exactly as it does for a
// normal create. Guarded because a drag is a user gesture that must never throw: if that
// button or its handler is missing, the drop should quietly do nothing rather than raise an
// uncaught error in the middle of an interaction.
const addBtn = document.getElementById('addScheduleBtn');
if (!addBtn || typeof addBtn.onclick !== 'function') return;
addBtn.onclick();
const hhmm = (m) => `${String(Math.floor(m / 60) % 24).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
document.getElementById('schedStart').value = hhmm(startMin);
document.getElementById('schedEnd').value = hhmm(endMin);
const startEl = document.getElementById('schedStart');
const endEl = document.getElementById('schedEnd');
if (startEl) startEl.value = hhmm(startMin);
if (endEl) endEl.value = hhmm(endMin);
pendingCreateDate = dayDate;
}
let pendingCreateDate = null;