/* ── 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}">✓</button>
<button class="cat-inline-cancel" id="catEditCancel">✕</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">✓</button>
<button class="cat-inline-cancel" id="catNewCancel">✕</button>
</div>`;
} else {
return `
<button class="cat-manage-btn" id="catRenBtn">✎ rename</button>
<button class="cat-manage-btn danger" id="catDelBtn">× 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.