Source: 10b-tasks-events.js

/* ── Today's tasks — event binding ── */

/** Shared drag-state for board column DnD (set by dragstart, read by drop). */
let _boardDragTaskId = null;

/**
 * Binds all plan list event handlers after each render.
 * @param {HTMLElement[]} lists - Column list elements (To Do, In Progress, Done).
 */
function bindPlanEvents(lists) {
  const qa = (sel) => lists.flatMap((L) => [...L.querySelectorAll(sel)]);

  // WIP warn dismiss — { once: true } so re-renders don't stack listeners
  document.querySelectorAll('.wip-warn__dismiss').forEach((btn) => {
    btn.addEventListener(
      'click',
      () => {
        wipWarnDismissed = true;
        renderPlan();
      },
      { once: true }
    );
  });
  qa('.plan-text').forEach((span) => {
    span.addEventListener('click', () => {
      const pid = span.closest('.plan-item').dataset.pid;
      if (pid) {
        editingPlanId = pid;
        renderPlan();
      }
    });
  });

  // Category picker
  qa('.plan-cat-line').forEach((line) => {
    line.addEventListener('click', () => {
      const pid = line.dataset.pid;
      const picker = document.getElementById('pcp-' + pid);
      const isOpen = picker.classList.contains('open');
      lists.forEach((L) =>
        L.querySelectorAll('.plan-cat-picker.open').forEach((p) => p.classList.remove('open'))
      );
      if (!isOpen) picker.classList.add('open');
    });
  });
  qa('.plan-cat-picker .cat-opt').forEach((btn) => {
    btn.addEventListener('click', () => {
      const t = planTasks.find((t) => t.id === btn.dataset.pid);
      if (t) {
        t.tag = btn.dataset.cat;
        savePlan();
        renderPlan();
      }
    });
  });
  qa('.plan-cat-picker .cat-cancel').forEach((btn) => {
    btn.addEventListener('click', () => {
      document.getElementById('pcp-' + btn.dataset.pid).classList.remove('open');
    });
  });

  // + new epic inside picker
  qa('.pcat-add-btn').forEach((btn) => {
    btn.addEventListener('click', () => {
      btn.style.display = 'none';
      const form = document.getElementById('pcaf-' + btn.dataset.pid);
      form.classList.add('open');
      form.querySelector('.pcat-add-input').focus();
    });
  });
  qa('.pcat-add-ok').forEach((btn) => {
    btn.addEventListener('click', () => {
      const form = document.getElementById('pcaf-' + btn.dataset.pid);
      const input = form.querySelector('.pcat-add-input');
      const label = input.value.trim();
      if (!label) {
        input.focus();
        return;
      }
      if (categories.find((c) => c.label.toLowerCase() === label.toLowerCase())) {
        input.style.borderColor = '#C62828';
        input.focus();
        return;
      }
      const color = nextDistinctColor();
      const id = 'cat_' + Date.now();
      categories.push({ id, label, color });
      const t = planTasks.find((t) => t.id === btn.dataset.pid);
      if (t) t.tag = id;
      save();
      savePlan();
      renderTagRow();
      renderPlan();
    });
  });
  qa('.pcat-add-input').forEach((inp) => {
    inp.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') inp.closest('.pcat-add-form').querySelector('.pcat-add-ok').click();
      if (e.key === 'Escape')
        inp.closest('.pcat-add-form').querySelector('.pcat-add-cancel2').click();
    });
  });
  qa('.pcat-add-cancel2').forEach((btn) => {
    btn.addEventListener('click', () => {
      const form = document.getElementById('pcaf-' + btn.dataset.pid);
      form.classList.remove('open');
      const addBtn = form.closest('.pcat-add-row').querySelector('.pcat-add-btn');
      if (addBtn) addBtn.style.display = '';
    });
  });

  // Status change — handles pending/blocked entry creation and in-flight comment carry-over
  qa('.plan-status').forEach((sel) => {
    sel.addEventListener('change', () => {
      const t = planTasks.find((t) => t.id === sel.dataset.pid);
      if (!t) return;
      const prevStatus = t.status;
      const newStatus = sel.value;
      wlLog.info('planTask: status changed', { id: t.id, from: prevStatus, to: newStatus });

      // Capture in-flight typed text BEFORE re-render
      let liveTyped = null;
      if (_pendingCommentId === t.id) {
        const inp = document.getElementById('pc-inp-' + t.id);
        liveTyped = inp ? inp.value : _pendingCommentText;
      }

      t.status = newStatus;
      if (newStatus === 'done' && !t.completedAt) t.completedAt = Date.now();
      if (newStatus !== 'done') delete t.completedAt;

      // If child goes inprogress, promote parent too (unless already done)
      if (newStatus === 'inprogress' && t.parentId) {
        const parent = planTasks.find((p) => p.id === t.parentId);
        if (parent && parent.status === 'todo') {
          parent.status = 'inprogress';
        }
      }
      // When marking done, retire older versions of the same task
      if (newStatus === 'done') {
        planTasks
          .filter(
            (p) =>
              p.id !== t.id && p.text.toLowerCase() === t.text.toLowerCase() && p.status !== 'done'
          )
          .forEach((p) => {
            p.status = 'done';
            if (!p.completedAt) p.completedAt = t.completedAt;
          });
      }
      // Auto-complete parent when all its children are done
      if (newStatus === 'done' && t.parentId) {
        const parent = planTasks.find((p) => p.id === t.parentId);
        if (parent && parent.status !== 'done') {
          const siblings = planTasks.filter((c) => c.parentId === parent.id && c.date === t.date);
          if (siblings.length > 0 && siblings.every((c) => c.status === 'done' || c.id === t.id)) {
            parent.status = 'done';
            if (!parent.completedAt) parent.completedAt = Date.now();
          }
        }
      }
      // Auto-stop timer when active task is marked done
      if (newStatus === 'done' && activeTimer) {
        const timerEntry = entries.find((e) => e.id === activeTimer.entryId);
        if (timerEntry && timerEntry.text.toLowerCase() === t.text.toLowerCase()) {
          stopTimer();
        }
      }

      // Pending/blocked transitions
      const wasPB = prevStatus === 'pending' || prevStatus === 'blocked';
      const isPB = newStatus === 'pending' || newStatus === 'blocked';

      if (isPB && newStatus !== prevStatus) {
        if (!t.statusComments) t.statusComments = [];
        const last = t.statusComments[t.statusComments.length - 1];
        const inFlight = _pendingCommentId === t.id;

        if (wasPB && inFlight && last && !last.comment) {
          // Same comment session — just relabel the unsaved entry,
          // preserving the typed-but-unsaved text via _pendingCommentText.
          last.status = newStatus;
          _pendingCommentText = liveTyped != null ? liveTyped : _pendingCommentText || '';
          // _pendingCommentId stays set
        } else {
          // Fresh session
          t.statusComments.push({ status: newStatus, comment: '', ts: Date.now() });
          _pendingCommentId = t.id;
          _pendingCommentText = '';
        }
      } else if (!isPB) {
        // Leaving pending/blocked — only drop a trailing unsaved entry
        // if this task had an in-flight comment session (otherwise it could
        // be a deliberately-saved empty entry).
        if (_pendingCommentId === t.id && t.statusComments && t.statusComments.length) {
          const last = t.statusComments[t.statusComments.length - 1];
          if (!last.comment && (last.status === 'pending' || last.status === 'blocked')) {
            t.statusComments.pop();
          }
        }
        if (_pendingCommentId === t.id) {
          _pendingCommentId = null;
          _pendingCommentText = '';
        }
      }

      savePlan();
      renderPlan();
      renderCompleted();
    });
  });

  // Accept / skip / edit for status comment
  function saveComment(pid) {
    const t = planTasks.find((t) => t.id === pid);
    if (!t) {
      _pendingCommentId = null;
      _pendingCommentText = '';
      renderPlan();
      return;
    }
    if (!t.statusComments) t.statusComments = [];
    const inp = document.getElementById('pc-inp-' + pid);
    const val = inp ? inp.value.trim() : (_pendingCommentText || '').trim();
    const entry = [...t.statusComments].reverse().find((c) => c.status === t.status);
    if (entry) {
      if (val) {
        entry.comment = val;
      } else {
        // Empty accept behaves as skip — remove the entry so the row
        // collapses to "+ add reason" rather than reopening the input.
        t.statusComments = t.statusComments.filter((c) => c !== entry);
      }
    } else if (val) {
      t.statusComments.push({ status: t.status, comment: val, ts: Date.now() });
    }
    _pendingCommentId = null;
    _pendingCommentText = '';
    savePlan();
    renderPlan();
  }
  qa('.plan-comment-ok').forEach((btn) => {
    btn.addEventListener('click', () => saveComment(btn.dataset.pid));
  });
  qa('.plan-comment-skip').forEach((btn) => {
    btn.addEventListener('click', () => {
      const t = planTasks.find((t) => t.id === btn.dataset.pid);
      if (t && t.statusComments && t.statusComments.length) {
        const last = t.statusComments[t.statusComments.length - 1];
        if (!last.comment) t.statusComments.pop();
      }
      _pendingCommentId = null;
      _pendingCommentText = '';
      savePlan();
      renderPlan();
    });
  });
  qa('.plan-comment-edit').forEach((btn) => {
    btn.addEventListener('click', () => {
      const t = planTasks.find((t) => t.id === btn.dataset.pid);
      _pendingCommentId = btn.dataset.pid;
      if (t && t.statusComments) {
        const ac = [...t.statusComments].reverse().find((c) => c.status === t.status);
        _pendingCommentText = ac ? ac.comment || '' : '';
      } else {
        _pendingCommentText = '';
      }
      renderPlan();
    });
  });
  qa('.plan-comment-input').forEach((inp) => {
    // Mirror typed text into the in-flight buffer
    inp.addEventListener('input', () => {
      if (inp.dataset.pid === _pendingCommentId) _pendingCommentText = inp.value;
    });
    inp.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') saveComment(inp.dataset.pid);
      if (e.key === 'Escape') {
        const t = planTasks.find((t) => t.id === inp.dataset.pid);
        if (t && t.statusComments && t.statusComments.length) {
          const last = t.statusComments[t.statusComments.length - 1];
          if (!last.comment) t.statusComments.pop();
        }
        _pendingCommentId = null;
        _pendingCommentText = '';
        savePlan();
        renderPlan();
      }
    });
    // Auto-focus the in-flight input, with cursor at end
    if (inp.dataset.pid === _pendingCommentId) {
      inp.focus();
      const len = inp.value.length;
      try {
        inp.setSelectionRange(len, len);
      } catch (e) {
        // Silently skip — setSelectionRange may fail on certain input types in some browsers
      }
    }
  });

  // History expand/collapse
  qa('.plan-comment-history-toggle').forEach((btn) => {
    btn.addEventListener('click', () => {
      _expandedHistoryId = _expandedHistoryId === btn.dataset.pid ? null : btn.dataset.pid;
      renderPlan();
    });
  });

  // Dismiss handoff note
  qa('.plan-handoff-dismiss').forEach((btn) => {
    btn.addEventListener('click', (e) => {
      e.stopPropagation();
      try {
        const notes = JSON.parse(localStorage.getItem('wl_handoff') || '{}');
        delete notes[btn.dataset.task];
        localStorage.setItem('wl_handoff', JSON.stringify(notes));
      } catch (e) {
        wlLog.warn('plan-handoff-dismiss: failed to update wl_handoff in localStorage', e);
      }
      renderPlan();
    });
  });

  // Checkpoint: toggle open/closed
  qa('.cp-badge').forEach((btn) => {
    btn.addEventListener('click', (e) => {
      e.stopPropagation();
      const pid = btn.dataset.pid;
      if (_cpOpenIds.has(pid)) _cpOpenIds.delete(pid);
      else _cpOpenIds.add(pid);
      renderPlan();
      // Auto-focus add input when opening
      if (_cpOpenIds.has(pid)) {
        setTimeout(() => {
          const inp = document.querySelector(`.cp-add-input[data-pid="${pid}"]`);
          if (inp) inp.focus();
        }, 0);
      }
    });
  });

  // Checkpoint: toggle done (three-state: false → 'partial' → true → false)
  qa('.cp-check').forEach((el) => {
    el.addEventListener('click', (e) => {
      e.stopPropagation();
      const t = planTasks.find((t) => t.id === el.dataset.pid);
      if (!t || !t.checkpoints) return;
      const idx = parseInt(el.dataset.cpidx);
      const cur = t.checkpoints[idx].done;
      t.checkpoints[idx].done = cur === false ? 'partial' : cur === 'partial' ? true : false;
      savePlan();
      renderPlan();
    });
  });

  // Checkpoint: toggle done via label click; double-click to edit
  qa('.cp-label').forEach((lbl) => {
    lbl.addEventListener('click', (e) => {
      e.stopPropagation();
      const t = planTasks.find((t) => t.id === lbl.dataset.pid);
      if (!t || !t.checkpoints) return;
      const idx = parseInt(lbl.dataset.cpidx);
      const cur = t.checkpoints[idx].done;
      t.checkpoints[idx].done = cur === false ? 'partial' : cur === 'partial' ? true : false;
      savePlan();
      renderPlan();
    });
    lbl.addEventListener('dblclick', (e) => {
      e.stopPropagation();
      _cpEditId = lbl.dataset.pid;
      _cpEditIdx = parseInt(lbl.dataset.cpidx);
      renderPlan();
      setTimeout(() => {
        const inp = document.querySelector(
          '.cp-edit-input[data-pid="' + _cpEditId + '"][data-cpidx="' + _cpEditIdx + '"]'
        );
        if (inp) {
          inp.focus();
          inp.select();
        }
      }, 0);
    });
  });

  // Checkpoint: save/cancel inline edit
  qa('.cp-edit-input').forEach((inp) => {
    const save = () => {
      const val = inp.value.trim();
      const t = planTasks.find((t) => t.id === inp.dataset.pid);
      if (t && t.checkpoints && val) t.checkpoints[parseInt(inp.dataset.cpidx)].text = val;
      _cpEditId = null;
      _cpEditIdx = null;
      savePlan();
      renderPlan();
    };
    inp.addEventListener('keydown', (e) => {
      e.stopPropagation();
      if (e.key === 'Enter') {
        e.preventDefault();
        save();
      }
      if (e.key === 'Escape') {
        _cpEditId = null;
        _cpEditIdx = null;
        renderPlan();
      }
    });
    inp.addEventListener('blur', save);
    inp.addEventListener('click', (e) => e.stopPropagation());
  });

  // Checkpoint: delete
  qa('.cp-del-btn').forEach((btn) => {
    btn.addEventListener('click', (e) => {
      e.stopPropagation();
      const t = planTasks.find((t) => t.id === btn.dataset.pid);
      if (!t || !t.checkpoints) return;
      t.checkpoints.splice(parseInt(btn.dataset.cpidx), 1);
      savePlan();
      renderPlan();
    });
  });

  // Checkpoint: add on Enter
  qa('.cp-add-input').forEach((inp) => {
    inp.addEventListener('keydown', (e) => {
      if (e.key !== 'Enter') return;
      const val = inp.value.trim();
      if (!val) return;
      const t = planTasks.find((t) => t.id === inp.dataset.pid);
      if (!t) return;
      if (!Array.isArray(t.checkpoints)) t.checkpoints = [];
      t.checkpoints.push({
        id: 'cp' + Date.now() + Math.random().toString(36).slice(2),
        text: val,
        done: false,
      });
      savePlan();
      renderPlan();
      // Re-focus add input after render
      setTimeout(() => {
        const next = document.querySelector(`.cp-add-input[data-pid="${inp.dataset.pid}"]`);
        if (next) next.focus();
      }, 0);
    });
    inp.addEventListener('click', (e) => e.stopPropagation());
  });

  // Checkpoint: drag-to-reorder
  let _cpDragPid = null,
    _cpDragIdx = null;
  qa('.cp-row').forEach((row) => {
    row.addEventListener('dragstart', (e) => {
      _cpDragPid = row.dataset.pid;
      _cpDragIdx = parseInt(row.dataset.cpidx);
      e.dataTransfer.effectAllowed = 'move';
    });
    row.addEventListener('dragover', (e) => {
      e.preventDefault();
      e.dataTransfer.dropEffect = 'move';
      document
        .querySelectorAll('.cp-row.cp-drag-over')
        .forEach((r) => r.classList.remove('cp-drag-over'));
      row.classList.add('cp-drag-over');
    });
    row.addEventListener('dragleave', () => row.classList.remove('cp-drag-over'));
    row.addEventListener('drop', (e) => {
      e.preventDefault();
      row.classList.remove('cp-drag-over');
      const targetIdx = parseInt(row.dataset.cpidx);
      if (_cpDragPid !== row.dataset.pid || _cpDragIdx === null || _cpDragIdx === targetIdx) return;
      const t = planTasks.find((t) => t.id === _cpDragPid);
      if (!t || !t.checkpoints) return;
      const moved = t.checkpoints.splice(_cpDragIdx, 1)[0];
      t.checkpoints.splice(targetIdx, 0, moved);
      savePlan();
      renderPlan();
      _cpDragIdx = null;
      _cpDragPid = null;
    });
  });

  // Edit task text
  qa('.plan-edit-btn').forEach((btn) => {
    btn.addEventListener('click', () => {
      editingPlanId = btn.dataset.pid;
      renderPlan();
    });
  });

  const editOk = document.getElementById('planEditOk');
  if (editOk) {
    const saveEdit = () => {
      const inp = document.getElementById('planEditInput');
      const text = inp ? inp.value.trim() : '';
      if (!text) {
        editingPlanId = null;
        renderPlan();
        return;
      }
      const t = planTasks.find((t) => t.id === editOk.dataset.pid);
      if (t) t.text = text;
      editingPlanId = null;
      savePlan();
      renderPlan();
    };
    editOk.addEventListener('click', saveEdit);
    const inp = document.getElementById('planEditInput');
    if (inp) {
      inp.focus();
      inp.select();
      inp.addEventListener('keydown', (e) => {
        if (e.key === 'Enter') saveEdit();
        if (e.key === 'Escape') {
          editingPlanId = null;
          renderPlan();
        }
      });
    }
  }
  const editCancel = document.getElementById('planEditCancel');
  if (editCancel)
    editCancel.addEventListener('click', () => {
      editingPlanId = null;
      renderPlan();
    });

  // Start timer from task
  qa('.plan-log-btn').forEach((btn) => {
    btn.addEventListener('click', () => {
      const t = planTasks.find((t) => t.id === btn.dataset.pid);
      const text = btn.dataset.text;
      const tag = t ? t.tag || 'other' : selectedTag;
      if (activeTimer) stopTimer();
      const entry = {
        id: Date.now() + '',
        text,
        tag,
        ts: safeRoundedStart(),
        date: dk(new Date()),
      };
      entries.push(entry);
      if (t && (t.status === 'todo' || t.status === 'upcoming')) {
        t.status = 'inprogress';
        if (t.parentId) {
          const parent = planTasks.find((p) => p.id === t.parentId);
          if (parent && parent.status === 'todo') parent.status = 'inprogress';
        }
        savePlan();
        renderPlan();
      }
      ensureDayStarted();
      viewDate = new Date();
      save();
      startTimer(entry.id);
      render();
    });
  });

  // Delete task (children become orphaned top-level tasks)
  qa('.plan-del-btn').forEach((btn) => {
    btn.addEventListener('click', () => {
      planTasks = planTasks.filter((t) => t.id !== btn.dataset.pid);
      savePlan();
      renderPlan();
    });
  });

  // Split into subtasks
  qa('.plan-split-btn').forEach((btn) => {
    btn.addEventListener('click', (e) => {
      e.stopPropagation();
      splitInputId = splitInputId === btn.dataset.pid ? null : btn.dataset.pid;
      renderPlan();
      if (splitInputId) {
        const inp = document.getElementById('splitInp-' + splitInputId);
        if (inp) inp.focus();
      }
    });
  });
  qa('.plan-split-done').forEach((btn) => {
    btn.addEventListener('click', () => {
      splitInputId = null;
      renderPlan();
    });
  });
  qa('.plan-split-input').forEach((inp) => {
    inp.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') {
        const text = inp.value.trim();
        if (!text) return;
        const parentId = inp.closest('.plan-split-row').dataset.parent;
        const parent = planTasks.find((t) => t.id === parentId);
        planTasks.push({
          id: Date.now() + '',
          text,
          status: 'todo',
          date: dk(new Date()),
          tag: parent ? parent.tag : selectedTag,
          parentId,
        });
        savePlan();
        inp.value = '';
        renderPlan();
        // Re-focus the new input after re-render
        const newInp = document.getElementById('splitInp-' + parentId);
        if (newInp) newInp.focus();
      } else if (e.key === 'Escape') {
        splitInputId = null;
        renderPlan();
      }
    });
  });
}

/**
 * Moves a task to a new board column, updating its status and timer state.
 * Dropping into In Progress stops any running timer, creates a new time entry,
 * and starts tracking. Dropping into Done or To Do stops the active timer.
 * @param {string} taskId    - The plan task ID to move.
 * @param {string} newStatus - Target status: 'todo' | 'inprogress' | 'done'.
 */
function moveTaskToColumn(taskId, newStatus) {
  const t = planTasks.find((p) => p.id === taskId);
  if (!t) {
    wlLog.warn('board: moveTaskToColumn — task not found', { id: taskId });
    return;
  }
  if (t.status === newStatus) return;

  wlLog.info('board: moveTaskToColumn', { id: taskId, from: t.status, to: newStatus });
  t.status = newStatus;

  // Stop the active timer only if it was tracking this exact task
  const stopTimerIfMatches = () => {
    if (activeTimer) {
      const timerEntry = entries.find((e) => e.id === activeTimer.entryId);
      if (timerEntry && timerEntry.text.toLowerCase() === t.text.toLowerCase()) stopTimer();
    }
  };

  if (newStatus === 'done') {
    if (!t.completedAt) t.completedAt = Date.now();
    stopTimerIfMatches();
  } else if (newStatus === 'todo') {
    delete t.completedAt;
    stopTimerIfMatches();
  } else if (newStatus === 'inprogress') {
    delete t.completedAt;
    // Stop any active timer unconditionally — only one task can be tracked at a time
    if (activeTimer) stopTimer();
    const entry = {
      id: Date.now() + '',
      text: t.text,
      tag: t.tag || 'other',
      ts: safeRoundedStart(),
      date: dk(new Date()),
    };
    entries.push(entry);
    save();
    startTimer(entry.id);
  }

  savePlan();
  renderPlan();
}

/**
 * Makes each rendered board card draggable and wires its dragstart/dragend.
 * Called once per `renderPlan()` cycle after columns are populated.
 * Static column drop-zone listeners are set up once in `initBoardColumnDnD()`.
 */
function bindBoardColumnDnD() {
  document.querySelectorAll('.kb-cards > .plan-item').forEach((card) => {
    card.setAttribute('draggable', 'true');
    card.addEventListener('dragstart', (e) => {
      _boardDragTaskId = card.dataset.pid;
      e.dataTransfer.effectAllowed = 'move';
      card.classList.add('kb-dragging');
    });
    card.addEventListener('dragend', () => {
      card.classList.remove('kb-dragging');
      document
        .querySelectorAll('.kb-col--drop-over')
        .forEach((el) => el.classList.remove('kb-col--drop-over'));
    });
  });
}

/**
 * Registers dragover, dragleave, and drop listeners on the three static board
 * column lists. Called exactly once on DOMContentLoaded from `07-lifecycle.js`.
 * Card draggable wiring (re-rendered each cycle) stays in `bindBoardColumnDnD()`.
 */
function initBoardColumnDnD() {
  const COLUMN_MAP = {
    planList: 'todo',
    progressList: 'inprogress',
    doneList: 'done',
  };

  Object.keys(COLUMN_MAP).forEach((listId) => {
    const listEl = document.getElementById(listId);
    if (!listEl) return;

    listEl.addEventListener('dragover', (e) => {
      e.preventDefault();
      e.dataTransfer.dropEffect = 'move';
      listEl.closest('.kb-col').classList.add('kb-col--drop-over');
    });
    listEl.addEventListener('dragleave', (e) => {
      // Only remove highlight when truly leaving the column (not a child element)
      if (!listEl.closest('.kb-col').contains(e.relatedTarget)) {
        listEl.closest('.kb-col').classList.remove('kb-col--drop-over');
      }
    });
    listEl.addEventListener('drop', (e) => {
      e.preventDefault();
      listEl.closest('.kb-col').classList.remove('kb-col--drop-over');
      if (_boardDragTaskId) {
        moveTaskToColumn(_boardDragTaskId, COLUMN_MAP[listId]);
        _boardDragTaskId = null;
      }
    });
  });
}