/* ── Epic helpers ── */
// safeCssColor() and escHtml() are defined in 00-pure-fns.js.
/**
* Returns the category object for `id`, falling back to 'other' if not found.
* The returned colour is always sanitised through safeCssColor.
* @param {string} id - Category ID.
* @returns {{ id: string, label: string, color: string }}
*/
function getCat(id) {
const cat = categories.find((c) => c.id === id) || categories.find((c) => c.id === 'other');
if (!cat) return { id: 'other', label: 'other', color: '#888780' };
return { ...cat, color: safeCssColor(cat.color) };
}
function getCatColor(id) {
return getCat(id).color;
}
function getCatLabel(id) {
return getCat(id).label;
}
let editingCatId = null;
let addingNewCat = false;
/** Controls whether the epic manage row (rename/delete/add) is expanded. */
let catManageOpen = false;
function renderTagRow() {
const row = document.getElementById('tagRow');
const selCat = getCat(selectedTag);
// Build manage row content based on state
let manageHtml;
if (editingCatId) {
const c = getCat(editingCatId);
manageHtml = `<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) {
manageHtml = `<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 {
manageHtml = `
<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 ? '💸 non-billable' : '💰 billable'}</button>`;
}
// The manage row is open when explicitly toggled, or when an inline edit is active.
const manageRowOpen = catManageOpen || !!editingCatId || addingNewCat;
row.innerHTML = `
<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="${selCat.color}" style="opacity:0;position:absolute;width:0;height:0;pointer-events:none" />
</label>
<select class="cat-select" id="catSelect">
${[...categories]
.sort((a, b) => a.label.localeCompare(b.label))
.map(
(c) =>
`<option value="${c.id}"${c.id === selectedTag ? ' selected' : ''}>${escHtml(c.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>`;
// Select change
document.getElementById('catSelect').addEventListener('change', (e) => {
selectedTag = e.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 = quickColorPick.value;
});
quickColorPick.addEventListener('change', () => {
const cat = categories.find((c) => c.id === selectedTag);
if (cat) {
cat.color = quickColorPick.value;
save();
renderTagRow();
render();
renderTimeblock();
renderCompleted();
}
});
}
// Rename: open
const renBtn = document.getElementById('catRenBtn');
if (renBtn)
renBtn.addEventListener('click', () => {
editingCatId = selectedTag;
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 (categories.find((c) => c.id !== id && c.label.toLowerCase() === label.toLowerCase())) {
input.style.borderColor = '#C62828';
input.focus();
return;
}
const cat = categories.find((c) => c.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', (e) => {
if (e.key === 'Enter') saveEdit();
if (e.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', () => {
categories = categories.filter((c) => c.id !== selectedTag);
selectedTag = 'work';
save();
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(selectedTag);
cat.billable = cat.billable === false;
// Retroactively update all tasks with this category
planTasks.forEach((t) => {
if (t.tag === selectedTag) t.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 (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 });
selectedTag = 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', (e) => {
if (e.key === 'Enter') saveNew();
if (e.key === 'Escape') {
addingNewCat = false;
renderTagRow();
}
});
}
}
// Add: cancel
const newCancel = document.getElementById('catNewCancel');
if (newCancel)
newCancel.addEventListener('click', () => {
addingNewCat = false;
catManageOpen = false;
renderTagRow();
});
}
/* ── Utility ── */
// dk(), fmtTime(), fmtElapsed(), roundUp30(), roundToNearest30(), safeCssColor(), escHtml()
// are defined in 00-pure-fns.js (concatenated earlier) so they are in scope here.
/**
* Returns true if `d` falls on today's calendar date (UTC).
* @param {Date} d
* @returns {boolean}
*/
function isToday(d) {
return dk(d) === dk(new Date());
}
/**
* Returns a human-readable day label: 'today', 'yesterday', or a short locale date string.
* @param {Date} d
* @returns {string}
*/
function fmtLabel(d) {
if (isToday(d)) return 'today';
const diffMs = new Date(dk(new Date())) - new Date(dk(d));
const diffDays = Math.round(diffMs / 86400000);
if (diffDays === 1) return 'yesterday';
return d.toLocaleDateString('en', { weekday: 'short', month: 'short', day: 'numeric' });
}
/**
* Rounds `ts` to the nearest 30-minute mark only when `entry` is billable.
* Non-billable entries keep their exact timestamps for accurate reporting.
* @param {number} ts - Unix timestamp in milliseconds.
* @param {object|null} entry - Work-log entry; if null, always rounds.
* @returns {number} Timestamp, conditionally rounded.
*/
function roundToNearest30IfBillable(ts, entry) {
// Assumption: non-billable entries keep exact timestamps for accurate time reporting.
// Billable entries are rounded because clients are invoiced in 30-minute increments.
// Changing this requires updating the export format in 05-entries.js and DATA.md.
if (entry && !isEntryBillable(entry)) return ts;
return roundToNearest30(ts);
}
/**
* Returns a rounded start timestamp that does not overlap any existing entry for today.
* Prevents new entries from appearing to start before a prior entry's end time.
* @returns {number} Unix timestamp in milliseconds.
*/
function safeRoundedStart() {
const ts = roundToNearest30(Date.now());
const todayKey = dk(new Date());
const lastEnd = entries
.filter((e) => e.date === todayKey && e.tsEnd)
.reduce((max, e) => Math.max(max, e.tsEnd), 0);
return Math.max(ts, lastEnd);
}
/**
* Returns entries for the currently viewed date, sorted newest-first.
* @returns {Array<object>}
*/
function viewEntries() {
return entries
.filter((e) => e.date === dk(viewDate))
.slice()
.reverse();
}
/**
* Counts consecutive days with at least one logged entry, looking backwards from yesterday.
* Today is excluded so the streak only increments once the day has been completed.
* @returns {number}
*/
function calcStreak() {
const days = new Set(entries.map((e) => e.date));
let streak = 0;
const d = new Date();
d.setDate(d.getDate() - 1); // Start from yesterday, not today
while (days.has(dk(d))) {
streak++;
d.setDate(d.getDate() - 1);
}
return streak;
}
// escHtml() is defined in 00-pure-fns.js.