Source: 02-utils.js

/* ── Epic helpers ── */
// safeCssColor() and escHtml() are defined in 00-pure-fns.js.
// EpicCategory is declared in pure-fns-epics.js (concatenated earlier).

// getCat(), getCatColor(), and getCatLabel() have been extracted to
// src/js/cat-utils.js (leaf ES module, issue #336, extraction #16).

let editingCatId = null;
let addingNewCat = false;
/** Controls whether the epic manage row (rename/delete/add) is expanded. */
let catManageOpen = false;

/**
 * Archives every epic with no log entry and no board task in the last
 * EPIC_STALE_DAYS days, after confirming the list with the user. Archived
 * epics drop out of the pickers but keep their record, so historical entries
 * still resolve their label and colour through getCat().
 *
 * Impure by design: it reads the module-level state the tag row already owns
 * (categories, entries, planTasks, selectedTag) and persists through save().
 * The decision itself lives in findStaleCategories (pure-fns-epics.js).
 * @returns {number} How many epics were archived (0 when none were stale or the user cancelled).
 */
function tidyStaleEpics() {
  const categories = getCategories();
  const { staleIds, cutoffIso } = findStaleCategories({
    categories,
    entries: getEntries(),
    planTasks: getPlanTasks(),
    todayIso: dk(new Date()),
    windowDays: EPIC_STALE_DAYS,
    selectedTag: getSelectedTag(),
  });
  wlLog.info('tidyStaleEpics: scanned epics for inactivity', {
    total: categories.length,
    stale: staleIds.length,
    cutoffIso,
    windowDays: EPIC_STALE_DAYS,
  });
  if (!staleIds.length) {
    window.alert(`No epics to tidy — every epic has been used since ${cutoffIso}.`);
    return 0;
  }
  const labels = staleIds.map((id) => `  • ${getCatLabel(id)}`).join('\n');
  const proceed = window.confirm(
    `Archive ${staleIds.length} epic(s) unused since ${cutoffIso}?\n\n${labels}\n\n` +
      'They will be hidden from the epic pickers. Past log entries keep their ' +
      'label and colour, and you can restore any of them from 🗂 epics.'
  );
  if (!proceed) {
    wlLog.info('tidyStaleEpics: user cancelled', { stale: staleIds.length });
    return 0;
  }
  setCategories(applyEpicArchive(categories, staleIds));
  save();
  wlLog.info('tidyStaleEpics: archived stale epics', { archived: staleIds.length, cutoffIso });
  return staleIds.length;
}

/**
 * Deletes the currently selected epic after confirming with the user, unless
 * it is a built-in (PROTECTED_CAT_IDS: work, meeting, focus, break, other) —
 * those are never offered for deletion at all, since 'work' is the hardcoded
 * startup/reset fallback and 'other' is getCat()'s own fallback for unknown
 * tags. A selectedTag that matches no stored epic (stale after an import or
 * a delete elsewhere) is also refused, because getCat() would silently
 * substitute 'other' and the function would save and log a deletion that
 * never happened.
 *
 * Unlike tidyStaleEpics()/archiving, this is a hard delete: the category
 * record itself is removed, not flagged. Any log entry or board task still
 * tagged with this epic keeps that tag string, but getCat() can no longer
 * resolve it and falls back to 'other' — permanently, since there is nothing
 * left to restore. The confirm text says so explicitly and names how many
 * entries/tasks would be affected, mirroring tidyStaleEpics()'s pattern of
 * showing the user what is about to happen before it happens.
 * @returns {boolean} True if the epic was deleted, false if blocked, unknown
 *   or the user declined the confirm.
 */
function deleteSelectedEpic() {
  const selectedTag = getSelectedTag();
  if (PROTECTED_CAT_IDS.includes(selectedTag)) {
    wlLog.warn('deleteSelectedEpic: refused to delete a built-in epic', {
      selectedTag,
    });
    window.alert(`"${getCatLabel(selectedTag)}" is a built-in epic and can't be deleted.`);
    return false;
  }
  if (!getCategories().some((category) => category.id === selectedTag)) {
    wlLog.warn('deleteSelectedEpic: selected epic does not exist, nothing deleted', {
      selectedTag,
    });
    return false;
  }
  const cat = getCat(selectedTag);
  const usageCount =
    getEntries().filter((entry) => entry.tag === selectedTag).length +
    getPlanTasks().filter((task) => task.tag === selectedTag).length;

  const warning =
    usageCount > 0
      ? `${usageCount} log entr${usageCount === 1 ? 'y' : 'ies'}/task(s) tagged with it will ` +
        `permanently fall back to "other" — this cannot be undone.`
      : `It has no log entries or tasks tagged with it. This cannot be undone.`;
  const proceed = window.confirm(`Delete the epic "${cat.label}"?\n\n${warning}`);
  if (!proceed) {
    wlLog.info('deleteSelectedEpic: user cancelled', { catId: cat.id });
    return false;
  }

  setCategories(getCategories().filter((category) => category.id !== selectedTag));
  setSelectedTag('work');
  save();
  wlLog.info('deleteSelectedEpic: deleted epic', { catId: cat.id, usageCount });
  return true;
}

/**
 * Re-renders every surface that offers an epic picker, so an archive or
 * restore is reflected everywhere at once rather than only in the modal.
 * @returns {void}
 */
function refreshEpicPickers() {
  renderTagRow();
  render();
  renderPlan();
}

/**
 * Fills the epics modal with the archived-epic list and binds one restore
 * button per row.
 *
 * Rows are keyed by list index (`epicRestore-0`, `epicRestore-1`, …) rather
 * than by epic ID: IDs come from persisted data and would need escaping to be
 * safe in an attribute *and* matching unescaped for the lookup, so an index
 * sidesteps that mismatch entirely.
 * @returns {void}
 */
function renderEpicsManager() {
  const body = document.getElementById('epicsManagerBody');
  if (!body) return;

  const archived = getCategories().filter((cat) => cat.archived);
  body.innerHTML = archived.length
    ? `<ul class="epics-list">` +
      archived
        .map(
          (cat, idx) =>
            `<li class="epics-list__item">` +
            `<span class="epics-list__dot" style="background:${safeCssColor(cat.color)}" aria-hidden="true"></span>` +
            `<span class="epics-list__label">${escHtml(cat.label)}</span>` +
            `<button class="epics-list__restore" id="epicRestore-${idx}"` +
            ` aria-label="Restore ${escHtml(cat.label)}">restore</button>` +
            `</li>`
        )
        .join('') +
      `</ul>`
    : `<p class="epics-list__empty">No archived epics — every epic is showing in the pickers.</p>`;

  archived.forEach((cat, idx) => {
    const btn = document.getElementById(`epicRestore-${idx}`);
    if (!btn) return;
    btn.addEventListener('click', () => {
      setCategories(restoreArchivedCategory(getCategories(), cat.id));
      save();
      wlLog.info('renderEpicsManager: restored archived epic', { catId: cat.id });
      renderEpicsManager();
      refreshEpicPickers();
    });
  });
}

/**
 * Wires the epics modal: the toolbar button that opens it, the tidy action,
 * and the close button. Called once at startup (12c-startup.js).
 *
 * These controls used to live in the epic manage strip inside #tagRow, which
 * is `display:none` in work-log.html — so archiving could be triggered from
 * five pickers but never undone. The modal is the reachable home for them.
 * @returns {void}
 */
function bindEpicsManager() {
  const openBtn = document.getElementById('epicsBtn');
  const overlay = document.getElementById('epicsOverlay');
  const closeBtn = document.getElementById('epicsClose');
  const tidyBtn = document.getElementById('epicsTidyBtn');
  if (!openBtn || !overlay) return;

  /**
   * Hides the modal and hands focus back to the button that opened it, so a
   * keyboard user is not dropped at the top of the document.
   * @returns {void}
   */
  const closeEpicsModal = () => {
    overlay.classList.remove('show');
    openBtn.focus();
  };

  openBtn.addEventListener('click', () => {
    renderEpicsManager();
    overlay.classList.add('show');
    if (closeBtn) closeBtn.focus();
  });

  if (closeBtn) closeBtn.addEventListener('click', closeEpicsModal);

  // Escape and backdrop-click dismissal, matching the other modals built on
  // this overlay shell (12a-changelog.js's expiry modal, 12c-gapreport.js).
  overlay.addEventListener('keydown', (event) => {
    if (event.key === 'Escape') closeEpicsModal();
  });

  overlay.addEventListener('click', (event) => {
    if (event.target === overlay) closeEpicsModal();
  });

  if (tidyBtn)
    tidyBtn.addEventListener('click', () => {
      if (tidyStaleEpics() > 0) {
        renderEpicsManager();
        refreshEpicPickers();
      }
    });
}

/**
 * Builds the inner markup of the epic manage row for whichever inline mode
 * is active. The modes are mutually exclusive and tested in priority order:
 * rename, add-new, then the idle button strip.
 *
 * Reads the module-level mode flags (editingCatId, addingNewCat) rather than
 * taking them as parameters, matching how the rest of this concatenated file
 * owns that state. Archive and restore are not handled here - #385 moved them
 * into the epics manager modal (renderEpicsManager/bindEpicsManager above).
 * @param {EpicCategory} selCat - The currently selected epic, already colour-sanitised by getCat().
 * @returns {string} HTML for the inside of #catManageRow.
 */
function buildManageRowHtml(selCat) {
  if (editingCatId) {
    const c = getCat(editingCatId);
    return `<div class="cat-inline-edit">
        <input class="cat-inline-input" id="catEditInput" value="${escHtml(c.label)}" data-id="${editingCatId}" />
        <button class="cat-inline-ok" id="catEditOk" data-id="${editingCatId}">&#10003;</button>
        <button class="cat-inline-cancel" id="catEditCancel">&#10005;</button>
      </div>`;
  } else if (addingNewCat) {
    return `<div class="cat-inline-edit">
        <input class="cat-inline-input" id="catNewInput" placeholder="new epic name" style="flex:1" />
        <button class="cat-inline-ok" id="catNewOk">&#10003;</button>
        <button class="cat-inline-cancel" id="catNewCancel">&#10005;</button>
      </div>`;
  } else {
    return `
        <button class="cat-manage-btn" id="catRenBtn">&#9998; rename</button>
        <button class="cat-manage-btn danger" id="catDelBtn">&#215; delete</button>
        <button class="cat-manage-btn add" id="catAddBtn">+ add epic</button>
        <button class="cat-manage-btn" id="catBillBtn">${selCat.billable === false ? '💸 internal' : '💰 billable'}</button>`;
  }
}

/**
 * Builds the complete #tagRow markup: the epic dropdown row plus the manage
 * row. Touches no DOM of its own - it reads module state and returns a string,
 * so the markup can be produced and asserted on independently of the listener
 * wiring in bindTagRowEvents().
 * @returns {string} HTML to assign to #tagRow.
 */
function buildTagRowHtml() {
  const selectedTag = getSelectedTag();
  const selCat = getCat(selectedTag);
  const manageHtml = buildManageRowHtml(selCat);

  // The manage row is open when explicitly toggled, or when an inline edit is active.
  const manageRowOpen = catManageOpen || !!editingCatId || addingNewCat;

  return `
      <div class="cat-dropdown-row">
        <label class="cat-color-swatch cat-dot-preview" id="catDotPreview" title="click to change colour" style="background:${safeCssColor(selCat.color)}">
          <input type="color" id="catQuickColorPick" value="${safeCssColor(selCat.color)}" style="opacity:0;position:absolute;width:0;height:0;pointer-events:none" />
        </label>
        <select class="cat-select" id="catSelect" aria-label="Select epic">
        ${pickableCategories([...getCategories()], selectedTag)
          .sort((a, b) => a.label.localeCompare(b.label))
          .map(
            (category) =>
              `<option value="${category.id}"${category.id === selectedTag ? ' selected' : ''}>${escHtml(category.label)}</option>`
          )
          .join('')}
        </select>
        <button class="cat-settings-btn${manageRowOpen ? ' open' : ''}"
                id="catSettingsBtn"
                title="Manage epic (rename, delete, add)"
                aria-label="Manage epic settings"
                aria-expanded="${manageRowOpen}">⚙</button>
      </div>
      <div class="cat-manage-row${manageRowOpen ? ' open' : ''}" id="catManageRow">${manageHtml}</div>`;
}

/**
 * Wires every listener for the markup buildTagRowHtml() just produced.
 *
 * Each lookup past the always-present dropdown controls is null-guarded: only
 * one inline mode is in the DOM at a time, so most of these elements are
 * absent on any given render.
 * @returns {void}
 */
function bindTagRowEvents() {
  // Select change
  document.getElementById('catSelect').addEventListener('change', (event) => {
    setSelectedTag(event.target.value);
    editingCatId = null;
    addingNewCat = false;
    renderTagRow();
  });

  // Settings toggle — opens/closes the manage row (disabled while an inline edit is active)
  document.getElementById('catSettingsBtn')?.addEventListener('click', () => {
    if (editingCatId || addingNewCat) return;
    catManageOpen = !catManageOpen;
    renderTagRow();
  });

  // Quick colour picker — click the dot to change colour immediately
  const quickColorPick = document.getElementById('catQuickColorPick');
  if (quickColorPick) {
    quickColorPick.addEventListener('input', () => {
      const dot = document.getElementById('catDotPreview');
      if (dot) dot.style.background = safeCssColor(quickColorPick.value);
    });
    quickColorPick.addEventListener('change', () => {
      const cat = getCategories().find((category) => category.id === getSelectedTag());
      if (cat) {
        cat.color = safeCssColor(quickColorPick.value);
        save();
        renderTagRow();
        render();
        renderTimeblock();
        renderCompleted();
      }
    });
  }

  // Rename: open
  const renBtn = document.getElementById('catRenBtn');
  if (renBtn)
    renBtn.addEventListener('click', () => {
      editingCatId = getSelectedTag();
      addingNewCat = false;
      renderTagRow();
    });

  // Rename: save
  const editOk = document.getElementById('catEditOk');
  if (editOk) {
    const saveEdit = () => {
      const input = document.getElementById('catEditInput');
      const label = input ? input.value.trim() : '';
      const id = editOk.dataset.id;
      if (!label) {
        editingCatId = null;
        renderTagRow();
        return;
      }
      if (
        getCategories().find(
          (category) => category.id !== id && category.label.toLowerCase() === label.toLowerCase()
        )
      ) {
        markInputInvalid(input);
        return;
      }
      const cat = getCategories().find((category) => category.id === id);
      if (cat) cat.label = label;
      editingCatId = null;
      catManageOpen = false;
      save();
      renderTagRow();
      render();
      renderTimeblock();
      renderCompleted();
    };
    editOk.addEventListener('click', saveEdit);
    const editInput = document.getElementById('catEditInput');
    if (editInput) {
      editInput.focus();
      editInput.select();
      editInput.addEventListener('keydown', (event) => {
        if (event.key === 'Enter') saveEdit();
        if (event.key === 'Escape') {
          editingCatId = null;
          renderTagRow();
        }
      });
    }
  }

  // Rename: cancel
  const editCancel = document.getElementById('catEditCancel');
  if (editCancel)
    editCancel.addEventListener('click', () => {
      editingCatId = null;
      catManageOpen = false;
      renderTagRow();
    });

  // Delete
  const delBtn = document.getElementById('catDelBtn');
  if (delBtn)
    delBtn.addEventListener('click', () => {
      if (deleteSelectedEpic()) {
        renderTagRow();
        render();
      }
    });

  // Add: open
  const addBtn = document.getElementById('catAddBtn');
  if (addBtn)
    addBtn.addEventListener('click', () => {
      addingNewCat = true;
      editingCatId = null;
      renderTagRow();
    });

  const billBtn = document.getElementById('catBillBtn');
  if (billBtn)
    billBtn.addEventListener('click', () => {
      const cat = getCat(getSelectedTag());
      cat.billable = cat.billable === false;
      // Retroactively update all tasks with this category
      getPlanTasks().forEach((task) => {
        if (task.tag === getSelectedTag()) task.billable = cat.billable;
      });
      save();
      savePlan();
      renderTagRow();
      renderPlan();
      renderCompleted();
    });

  // Add: save
  const newOk = document.getElementById('catNewOk');
  if (newOk) {
    const saveNew = () => {
      const input = document.getElementById('catNewInput');
      const label = input ? input.value.trim() : '';
      if (!label) {
        addingNewCat = false;
        renderTagRow();
        return;
      }
      if (
        getCategories().find((category) => category.label.toLowerCase() === label.toLowerCase())
      ) {
        markInputInvalid(input);
        return;
      }
      const color = nextDistinctColor();
      const id = 'cat_' + Date.now();
      getCategories().push({ id, label, color });
      setSelectedTag(id);
      addingNewCat = false;
      catManageOpen = false;
      document.getElementById('captureInput').value = '';
      save();
      renderTagRow();
      render();
    };
    newOk.addEventListener('click', saveNew);
    const newCatInput = document.getElementById('catNewInput');
    if (newCatInput) {
      newCatInput.focus();
      newCatInput.addEventListener('keydown', (event) => {
        if (event.key === 'Enter') saveNew();
        if (event.key === 'Escape') {
          addingNewCat = false;
          renderTagRow();
        }
      });
    }
  }

  // Add: cancel
  const newCancel = document.getElementById('catNewCancel');
  if (newCancel)
    newCancel.addEventListener('click', () => {
      addingNewCat = false;
      catManageOpen = false;
      renderTagRow();
    });
}

/**
 * Renders the epic tag row and rebinds its listeners. Every state change in the
 * handlers below funnels back through here, so the row is always rebuilt
 * wholesale rather than patched in place.
 * @returns {void}
 */
function renderTagRow() {
  const row = document.getElementById('tagRow');
  row.innerHTML = buildTagRowHtml();
  bindTagRowEvents();
}

/* ── Utility ── */
// dk(), fmtTime(), fmtElapsed(), roundToNearest30(), safeCssColor(), escHtml()
// are defined in 00-pure-fns.js (concatenated earlier) so they are in scope here.
// isToday() and fmtLabel() are defined in date-labels.js (a leaf ES module
// imported at the top of the built bundle), not here.
// isEntryBillable() and roundToNearest30IfBillable() have been extracted to
// src/js/entry-billable.js (leaf ES module, issue #336, extraction #19).

// safeRoundedStart() and viewEntries() were extracted to entry-utils.js
// (issue #336, extraction #18). They are injected into this concatenated scope
// by build.js because entry-utils.js is a LEAF_MODULE that precedes this file
// in the bundle order.
/**
 * Tracks inputs that already have a pending clear-on-edit listener, so that
 * calling markInputInvalid() repeatedly before the user edits does not stack
 * multiple listeners on the same element.
 * @type {WeakSet<HTMLInputElement>}
 */
const _invalidListenerPending = new WeakSet();

/**
 * Flags a text input as rejected: red border via `.input--invalid`, exposed to
 * assistive tech through `aria-invalid`, and focused. The mark clears itself
 * on the next edit so a corrected value is not left looking wrong.
 *
 * Safe to call multiple times without an intervening edit: the clear-on-edit
 * listener is added only once per input.
 * @param {HTMLInputElement} input - The input whose value was rejected.
 * @returns {void}
 */
function markInputInvalid(input) {
  input.classList.add('input--invalid');
  input.setAttribute('aria-invalid', 'true');
  if (!_invalidListenerPending.has(input)) {
    _invalidListenerPending.add(input);
    input.addEventListener(
      'input',
      () => {
        input.classList.remove('input--invalid');
        input.removeAttribute('aria-invalid');
        _invalidListenerPending.delete(input);
      },
      { once: true }
    );
  }
  input.focus();
}

// calcStreak() is a pure function (testable without DOM) — lives in pure-fns-format.js.

// trapFocusInOverlay() was extracted to focus-utils.js (issue #336, extraction #17).
// It is injected into this concatenated scope by build.js because focus-utils.js
// is a LEAF_MODULE that precedes this file in the bundle order.