Source: 10c-tasks-board.js

/* ── Today's tasks — board DnD, tabs & live strip ── */

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

/**
 * 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'.
 * @returns {void}
 */
function moveTaskToColumn(taskId, newStatus) {
  const t = getPlanTasks().find((planTask) => planTask.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 (getActiveTimer()) {
      const timerEntry = getEntries().find((logEntry) => logEntry.id === getActiveTimer().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 (getActiveTimer()) stopTimer();
    const entry = {
      id: Date.now() + '',
      text: t.text,
      tag: t.tag || 'other',
      ts: safeRoundedStart(),
      date: dk(new Date()),
    };
    getEntries().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()`.
 * @returns {void}
 */
function bindBoardColumnDnD() {
  document.querySelectorAll('.kb-cards > .plan-item').forEach((card) => {
    card.setAttribute('draggable', 'true');
    card.addEventListener('dragstart', (event) => {
      _boardDragTaskId = card.dataset.pid;
      event.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()`.
 * @returns {void}
 */
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', (event) => {
      event.preventDefault();
      event.dataTransfer.dropEffect = 'move';
      listEl.closest('.kb-col').classList.add('kb-col--drop-over');
    });
    listEl.addEventListener('dragleave', (event) => {
      // Only remove highlight when truly leaving the column (not a child element)
      if (!listEl.closest('.kb-col').contains(event.relatedTarget)) {
        listEl.closest('.kb-col').classList.remove('kb-col--drop-over');
      }
    });
    listEl.addEventListener('drop', (event) => {
      event.preventDefault();
      listEl.closest('.kb-col').classList.remove('kb-col--drop-over');
      if (_boardDragTaskId) {
        // eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
        moveTaskToColumn(_boardDragTaskId, COLUMN_MAP[listId]);
        _boardDragTaskId = null;
      }
    });
  });
}

/**
 * Initialises the tabbed board: wires tab-click handlers, restores the last
 * active tab from localStorage, and sets up drag-over-tab lane switching so
 * users can drag a card onto a tab label to reveal that column before dropping.
 * Called once on DOMContentLoaded from `07-lifecycle.js`.
 * @returns {void}
 */
function initBoardTabs() {
  const BOARD_TAB_KEY = 'wl_board_tab';

  /**
   * Activates one board tab: marks it `is-active`, shows its column, hides the rest.
   * @param {string} tabId - One of 'todo' | 'inprogress' | 'done'.
   */
  function activateBoardTab(tabId) {
    document.querySelectorAll('.board-tab').forEach((btn) => {
      const active = btn.dataset.tab === tabId;
      btn.classList.toggle('is-active', active);
      btn.setAttribute('aria-selected', String(active));
      // Roving tabindex: only the active tab is in the tab order (WCAG 2.1.1)
      btn.tabIndex = active ? 0 : -1;
    });
    document.querySelectorAll('.kb-col[data-col]').forEach((col) => {
      col.classList.toggle('kb-col--active', col.dataset.col === tabId);
    });
    try {
      localStorage.setItem(BOARD_TAB_KEY, tabId);
    } catch (err) {
      wlLog.warn('initBoardTabs: localStorage.setItem failed (quota?)', err);
    }
  }

  const storedTab = (() => {
    try {
      return localStorage.getItem(BOARD_TAB_KEY);
    } catch (err) {
      wlLog.warn('initBoardTabs: localStorage.getItem failed', err);
      return null;
    }
  })();
  activateBoardTab(storedTab || 'todo');

  document.querySelectorAll('.board-tab').forEach((btn) => {
    btn.addEventListener('click', () => activateBoardTab(btn.dataset.tab));

    // Drag over a tab → switch to that lane so the card can be dropped there
    btn.addEventListener('dragover', (event) => {
      event.preventDefault();
      btn.classList.add('board-tab--drop');
      activateBoardTab(btn.dataset.tab);
    });
    btn.addEventListener('dragleave', () => btn.classList.remove('board-tab--drop'));
    btn.addEventListener('drop', () => btn.classList.remove('board-tab--drop'));
  });

  // Arrow-key navigation between tabs (WCAG SC 4.1.2 tablist pattern)
  const tabsEl = document.getElementById('boardTabs');
  if (tabsEl) {
    tabsEl.addEventListener('keydown', (event) => {
      const tabs = [...document.querySelectorAll('.board-tab')];
      const idx = tabs.findIndex((tab) => tab === document.activeElement);
      if (idx === -1) return;
      if (event.key === 'ArrowRight') {
        event.preventDefault();
        tabs[(idx + 1) % tabs.length].focus();
      } else if (event.key === 'ArrowLeft') {
        event.preventDefault();
        tabs[(idx - 1 + tabs.length) % tabs.length].focus();
      } else if (event.key === 'Home') {
        event.preventDefault();
        tabs[0].focus();
      } else if (event.key === 'End') {
        event.preventDefault();
        tabs[tabs.length - 1].focus();
      }
    });
  }
}

/**
 * Updates the `#boardLive` running-task strip above the board tabs.
 * Shows the strip (with task title, category, and elapsed clock) when a
 * non-paused timer is active; hides it otherwise.
 * Called from `renderPlan()` after every render cycle.
 * @returns {void}
 */
function updateBoardLive() {
  const stripEl = document.getElementById('boardLive');
  if (!stripEl) return;

  if (!getActiveTimer() || getActiveTimer().paused) {
    stripEl.hidden = true;
    return;
  }

  const liveEntry = getEntries().find((logEntry) => logEntry.id === getActiveTimer().entryId);
  if (!liveEntry) {
    stripEl.hidden = true;
    return;
  }

  if (!liveEntry.tag)
    wlLog.warn('updateBoardLive: entry has no tag, falling back to "other"', liveEntry.id);
  const cat = getCat(liveEntry.tag || 'other');
  const elapsed = fmtElapsed(getElapsedMs());

  stripEl.innerHTML = `<button class="board-live__card" id="boardLiveCard"
      aria-label="Currently tracking: ${escHtml(liveEntry.text)}">
    <span class="board-live__pulse" aria-hidden="true"></span>
    <span class="board-live__body">
      <span class="board-live__title">${escHtml(liveEntry.text)}</span>
      <span class="board-live__meta">
        <span class="board-live__dot" style="background:${escHtml(cat.color)}" aria-hidden="true"></span>
        ${escHtml(cat.label)}
      </span>
    </span>
    <span class="board-live__clock" id="boardLiveClock">${elapsed}</span>
  </button>`;
  stripEl.hidden = false;

  // Clicking the live strip navigates to the In Progress tab
  document.getElementById('boardLiveCard')?.addEventListener('click', () => {
    document.querySelector('.board-tab[data-tab="inprogress"]')?.click();
  });
}