/* ── Render ── */
/**
* Full application re-render: updates the date label, timer bar, stat counters,
* sub-stats, time-log list, chart, quick-pick, plan, completed section, and
* time-block view. Call whenever persistent state changes.
*
* Design trade-off: full DOM re-render on every change rather than targeted
* updates. Keeps state reasoning simple for a single-user personal tool where
* the entry list is small (typically < 50 items per day). If performance becomes
* a concern, the innermost `timelineEl.querySelectorAll` event-binding loop is the first
* candidate for optimisation (see phase 6 below).
*/
function render() {
/* ── 0. Hero Card state ── */
renderHeroCard();
/* ── 1. Date header and navigation ── */
document.getElementById('dateLabel').textContent = fmtLabel(viewDate);
document.getElementById('prevDay').disabled = false;
document.getElementById('nextDay').disabled = isToday(viewDate);
renderLocation();
// Session chip + end-the-day button track the day in view, so refresh them
// whenever the date changes.
renderSodBtn();
renderEodBtn();
/* ── 2. Timer bar ── */
if (!activeTimer) {
updateTimerBar();
updateTimerBtn(false);
} else {
updateTimerBar();
updateTimerBtn(true);
}
/* ── 3. Header stat tiles (distinct tasks today / epics this week / streak) ── */
const todayKey = dk(new Date());
document.getElementById('statToday').textContent = new Set(
entries.filter((e) => e.date === todayKey).map((e) => e.text.toLowerCase())
).size;
document.getElementById('statWeek').textContent = (() => {
const weekStart = new Date();
weekStart.setDate(weekStart.getDate() - ((weekStart.getDay() + 6) % 7));
weekStart.setHours(0, 0, 0, 0);
return new Set(entries.filter((e) => new Date(e.ts) >= weekStart).map((e) => e.tag || 'other'))
.size;
})();
document.getElementById('statStreak').textContent = calcStreak();
// Collapsed summary: mirrors the three values into a single header line so
// the section communicates its data without needing to be opened.
document.getElementById('analyticsSummary').textContent = [
`${document.getElementById('statToday').textContent} tasks today`,
`${document.getElementById('statWeek').textContent} epics this week`,
`${document.getElementById('statStreak').textContent}-day streak`,
].join(' · ');
/* ── 4. Sub-stat tiles (most-tracked task today / this week / best streak day) ── */
// taskSubHtml wraps fmtDur (defined in 00-pure-fns.js) with Jira-ticket-link logic.
// Emits structured divs so each line gets its own color token (link, title, value).
function taskSubHtml(label, ms) {
// Anchored on ^…$; [\s:_-]+ and .* overlap on whitespace but cannot catastrophically backtrack.
// eslint-disable-next-line security/detect-unsafe-regex
const m = label.match(/^([A-Z]+-\d+)([\s:_-]+(.*))?$/);
const ticket = m ? m[1] : null;
const name = m ? (m[3] || '').trim() : label;
const keyHtml = ticket
? `<a class="jira-key-link" href="${JIRA_BASE}/${ticket}" target="_blank" rel="noopener" onclick="event.stopPropagation()">${escHtml(ticket)}</a>`
: null;
return keyHtml
? `${keyHtml}${name ? `<div class="stat-sub-title">${escHtml(name)}</div>` : ''}<div class="stat-sub-value">${fmtDur(ms)}</div>`
: `<div class="stat-sub-title">${escHtml(label)}</div><div class="stat-sub-value">${fmtDur(ms)}</div>`;
}
// Today: task with most tracked time
const todayTimed = entries.filter((e) => e.date === todayKey && e.tsEnd && e.tsEnd > e.ts);
const todayByTask = {};
todayTimed.forEach((e) => {
const taskKey = e.text.toLowerCase();
if (!todayByTask[taskKey]) todayByTask[taskKey] = { label: e.text, ms: 0 };
todayByTask[taskKey].ms += e.tsEnd - e.ts;
});
const topTask = Object.values(todayByTask).sort((a, b) => b.ms - a.ms)[0];
const todaySub = document.getElementById('statTodaySub');
if (topTask) {
todaySub.innerHTML = taskSubHtml(topTask.label, topTask.ms);
todaySub.style.display = '';
} else {
todaySub.style.display = 'none';
}
// This week: task with most tracked time
const thisWeekStart = new Date();
thisWeekStart.setDate(thisWeekStart.getDate() - ((thisWeekStart.getDay() + 6) % 7));
thisWeekStart.setHours(0, 0, 0, 0);
const weekTimed = entries.filter(
(e) => new Date(e.ts) >= thisWeekStart && e.tsEnd && e.tsEnd > e.ts
);
const weekByTask = {};
weekTimed.forEach((e) => {
const taskKey = e.text.toLowerCase();
if (!weekByTask[taskKey]) weekByTask[taskKey] = { label: e.text, ms: 0 };
weekByTask[taskKey].ms += e.tsEnd - e.ts;
});
const topWeekTask = Object.values(weekByTask).sort((a, b) => b.ms - a.ms)[0];
const weekSub = document.getElementById('statWeekSub');
if (topWeekTask) {
weekSub.innerHTML = taskSubHtml(topWeekTask.label, topWeekTask.ms);
weekSub.style.display = '';
} else {
weekSub.style.display = 'none';
}
// Streak: day with longest tracked time
const streakDays = [];
{
const streakCursor = new Date();
streakCursor.setDate(streakCursor.getDate() - 1);
const daysWithEntries = new Set(entries.map((e) => e.date));
while (daysWithEntries.has(dk(streakCursor))) {
streakDays.push(dk(streakCursor));
streakCursor.setDate(streakCursor.getDate() - 1);
}
}
const streakSub = document.getElementById('statStreakSub');
if (streakDays.length > 0) {
let bestDay = null,
bestMs = 0;
streakDays.forEach((dateKey2) => {
const ms = entries
.filter((e) => e.date === dateKey2 && e.tsEnd && e.tsEnd > e.ts)
.reduce((s, e) => s + (e.tsEnd - e.ts), 0);
if (ms > bestMs) {
bestMs = ms;
bestDay = dateKey2;
}
});
if (bestDay && bestMs > 0) {
const bestStreakDay = new Date(bestDay + 'T12:00:00');
const dayName = isToday(bestStreakDay)
? 'today'
: bestStreakDay.toLocaleDateString('en', {
weekday: 'long',
month: 'long',
day: 'numeric',
});
streakSub.innerHTML = `<div class="stat-sub-title">Longest date tracked</div><div class="stat-sub-title">${escHtml(dayName)}</div><div class="stat-sub-value">${fmtDur(bestMs)}</div>`;
streakSub.style.display = '';
} else {
streakSub.style.display = 'none';
}
} else {
streakSub.style.display = 'none';
}
/* ── 5. Timeline ── */
const list = viewEntries();
const timelineEl = document.getElementById('timeline');
// Ad-hoc inline log row — shown only when viewing today, pinned at the bottom
const adHocRow = isToday(viewDate)
? `<div class="tl-adhoc-row">
<input class="tl-adhoc-input" id="tlAdHocInput"
aria-label="Log an entry directly in the time log"
placeholder="log something…" autocomplete="off"/>
<button class="tl-adhoc-btn" id="tlAdHocBtn" aria-label="Log entry">+ log</button>
</div>`
: '';
// Empty state: render sub-components (plan, timeblock) and bail out early
if (!list.length) {
timelineEl.innerHTML =
'<div class="empty-state">' +
(isToday(viewDate)
? 'nothing logged yet — type something below.'
: 'nothing was logged on this day.') +
'</div>' +
adHocRow;
const chartEl = document.getElementById('chart');
if (chartEl) chartEl.innerHTML = '';
renderQuickPick();
renderPlan();
renderCompleted();
renderTodayFlow();
renderTrackers();
return;
}
// Build entry row HTML — one <div class="entry"> per log entry; ad-hoc row pinned at bottom
timelineEl.innerHTML =
list
.map((e) => {
const isTiming = activeTimer && activeTimer.entryId === e.id;
const isPaused = isTiming && activeTimer.paused;
const color = getCatColor(e.tag);
const endLine = isTiming
? isPaused
? `<span class="etime-end" style="color:#EF9F27;font-size:10px;">paused</span>`
: `<span class="etime-end" style="color:#5DCAA5;font-size:10px;">timing…</span>`
: e.tsEnd
? `<span class="etime-end">↳ ${fmtTime(e.tsEnd)}</span>${durLabel(e.ts, e.tsEnd)}`
: `<span class="etime-end" style="color:var(--text3);font-style:italic;font-size:10px;">+ end time</span>`;
const catOpts =
categories
.map(
(c) =>
`<button class="cat-opt${e.tag === c.id ? ' sel' : ''}" data-id="${e.id}" data-cat="${c.id}" style="${e.tag === c.id ? `background:${safeCssColor(c.color)};` : ''}color:${e.tag === c.id ? '#fff' : safeCssColor(c.color)}">${escHtml(c.label)}</button>`
)
.join('') + `<button class="cat-cancel" data-id="${e.id}">cancel</button>`;
const startVal = toTimeInput(e.ts);
const endVal = e.tsEnd ? toTimeInput(e.tsEnd) : '';
const billableEmoji = isEntryBillable(e) ? '💰' : '💸';
return `
<div class="entry${isTiming ? ' is-timing' : ''}${e.signifier === 'cancelled' ? ' sig-cancelled-row' : ''}" data-id="${e.id}">
<div class="etime-col">
<span class="etime-display" data-id="${e.id}">
<span class="etime-start">${fmtTime(e.ts)}</span>
${endLine}
</span>
<div class="etime-editor" id="ed-${e.id}">
<div class="etime-editor-row"><span class="etime-lbl">start</span><input class="etime-input" type="time" id="ts-${e.id}" value="${startVal}" /></div>
<div class="etime-editor-row"><span class="etime-lbl">end</span><input class="etime-input" type="time" id="te-${e.id}" value="${endVal}" placeholder="--:--" /></div>
<div class="etime-actions">
<button class="etime-save" data-id="${e.id}">save</button>
<button class="etime-cancel" data-id="${e.id}">cancel</button>
</div>
</div>
</div>
${sigHtml(e)}
<span class="edot" style="background:${color};margin-top:6px;"></span>
<div class="ebody">
<div class="etext" data-id="${e.id}">${jiraTicketHtml(e.text)}${e._uncategorised ? `<span class="entry-uncategorised" title="No category — tap to assign">○</span>` : ''}</div>
<button class="etag-btn" data-id="${e.id}">
<span class="etag-cdot" style="background:${color}"></span>
${escHtml(getCatLabel(e.tag))} ▼
</button>
<div class="cat-picker" id="cp-${e.id}">${catOpts}</div>
</div>
<button class="ebill-btn" data-id="${e.id}" title="toggle billable/non-billable" style="cursor:pointer;background:none;border:none;padding:4px 8px;font-size:16px;color:inherit">${billableEmoji}</button>
<button class="erestart" data-id="${e.id}" title="restart with timer">▶</button>
<button class="edel" data-id="${e.id}" title="delete">×</button>
</div>`;
})
.join('') + adHocRow;
/* ── 6. Event binding (time editor, category picker, billable, delete, restart, rename) ── */
/* Ad-hoc log row */
const adHocBtn = document.getElementById('tlAdHocBtn');
const adHocInput = document.getElementById('tlAdHocInput');
if (adHocBtn && adHocInput) {
const commitAdHoc = () => {
const text = adHocInput.value.trim();
if (!text) {
adHocInput.focus();
return;
}
const entry = {
id: Date.now() + '',
text,
tag: selectedTag || (categories[0] ? categories[0].id : 'other'),
ts: safeRoundedStart(),
date: dk(new Date()),
};
entries.push(entry);
save();
render();
};
adHocBtn.addEventListener('click', commitAdHoc);
adHocInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') commitAdHoc();
});
// Prevent Space from opening the rapid-log overlay while typing here
adHocInput.addEventListener('keydown', (e) => {
if (e.code === 'Space') e.stopPropagation();
});
}
bindSignifierClicks();
/* time editor */
timelineEl.querySelectorAll('.etime-display').forEach((el) => {
el.addEventListener('click', () => {
const id = el.dataset.id;
closeAllEditors();
el.style.display = 'none';
document.getElementById('ed-' + id).classList.add('open');
});
});
timelineEl.querySelectorAll('.etime-save').forEach((btn) => {
btn.addEventListener('click', () => {
const id = btn.dataset.id,
entry = entries.find((e) => e.id === id);
if (!entry) return;
const newStartTime = document.getElementById('ts-' + id).value;
const newEndTime = document.getElementById('te-' + id).value;
if (newStartTime) entry.ts = roundToNearest30(applyTime(entry.ts, newStartTime));
if (newEndTime) entry.tsEnd = roundToNearest30(applyTime(entry.ts, newEndTime));
else delete entry.tsEnd;
// If this entry's timer is running, reset startTs to the new entry.ts
if (activeTimer && activeTimer.entryId === id && newStartTime) {
activeTimer.startTs = entry.ts;
activeTimer.accumulatedMs = 0;
activeTimer.paused = false;
}
save();
render();
});
});
timelineEl
.querySelectorAll('.etime-cancel')
.forEach((btn) => btn.addEventListener('click', () => render()));
/* category picker */
timelineEl.querySelectorAll('.etag-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const id = btn.dataset.id;
const picker = document.getElementById('cp-' + id);
const isOpen = picker.classList.contains('open');
document.querySelectorAll('.cat-picker.open').forEach((el) => el.classList.remove('open'));
if (!isOpen) picker.classList.add('open');
});
});
timelineEl.querySelectorAll('.cat-opt').forEach((btn) => {
btn.addEventListener('click', () => {
const entry = entries.find((e) => e.id === btn.dataset.id);
if (entry) {
const taskText = entry.text.toLowerCase();
entries.forEach((e) => {
if (e.text.toLowerCase() === taskText) e.tag = btn.dataset.cat;
});
save();
render();
}
});
});
timelineEl.querySelectorAll('.cat-cancel').forEach((btn) => {
btn.addEventListener('click', () => {
document.getElementById('cp-' + btn.dataset.id).classList.remove('open');
});
});
/* billable toggle */
timelineEl.querySelectorAll('.ebill-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const entry = entries.find((e) => e.id === btn.dataset.id);
if (entry) {
entry.billable = entry.billable === false ? undefined : false;
save();
render();
}
});
});
/* delete */
timelineEl.querySelectorAll('.edel').forEach((btn) => {
btn.addEventListener('click', () => {
const id = btn.dataset.id;
if (activeTimer && activeTimer.entryId === id) {
clearInterval(timerInterval);
timerInterval = null;
activeTimer = null;
save();
updateTimerBtn(false);
}
entries = entries.filter((e) => e.id !== id);
save();
render();
});
});
/* restart */
timelineEl.querySelectorAll('.erestart').forEach((btn) => {
btn.addEventListener('click', () => {
const sourceEntry = entries.find((e) => e.id === btn.dataset.id);
if (!sourceEntry) return;
if (activeTimer) stopTimer();
const newEntry = {
id: Date.now() + '',
text: sourceEntry.text,
tag: sourceEntry.tag,
ts: safeRoundedStart(),
date: dk(new Date()),
};
entries.push(newEntry);
viewDate = new Date();
save();
startTimer(newEntry.id);
render();
});
});
/* rename entry text (propagates to all entries + plan tasks with same text) */
timelineEl.querySelectorAll('.etext').forEach((el) => {
el.addEventListener('click', () => {
if (el.querySelector('.etext-input')) return;
const id = el.dataset.id;
const entry = entries.find((e) => e.id === id);
if (!entry) return;
const origText = entry.text;
const input = document.createElement('input');
input.className = 'etext-input';
input.value = origText;
el.innerHTML = '';
el.appendChild(input);
input.focus();
input.select();
let saved = false;
const doSave = () => {
if (saved) return;
saved = true;
const newText = input.value.trim();
if (newText && newText !== origText) {
const origLower = origText.toLowerCase();
entries.forEach((e) => {
if (e.text.toLowerCase() === origLower) e.text = newText;
});
planTasks.forEach((t) => {
if (t.text.toLowerCase() === origLower) t.text = newText;
});
save();
savePlan();
}
render();
};
input.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter') {
ev.preventDefault();
doSave();
}
if (ev.key === 'Escape') {
saved = true;
render();
}
});
input.addEventListener('blur', doSave);
});
});
renderQuickPick();
renderChart(list);
renderPlan();
renderCompleted();
renderTodayFlow();
renderTrackers();
}
/**
* Renders the "recent tasks" quick-pick bar below the capture input.
* Deduplicates entries by text, hides manually-dismissed tasks and tasks past
* their iteration expiry, and caps the list at 16 items.
*/
function renderQuickPick() {
const qp = document.getElementById('quickPick');
const seen = new Set();
// Build deduplicated recent list, then filter out hidden ones
const allRecent = [...entries].reverse().filter((e) => {
const k = e.text.toLowerCase();
if (seen.has(k)) return false;
seen.add(k);
return true;
});
// Hide tasks whose last-logged date is at or past the current iteration boundary
const todayKeyQp = dk(new Date());
const expiredQp = new Set(
allRecent
.filter((e) => {
const expiry = getIterationExpiry(e.date || '');
return expiry && todayKeyQp >= expiry;
})
.map((e) => e.text.toLowerCase())
);
const recent = allRecent
.filter((e) => !qpHidden.has(e.text.toLowerCase()) && !expiredQp.has(e.text.toLowerCase()))
.slice(0, 16);
// Hidden count is the intersection of qpHidden with task texts actually present in entries
const hiddenInUse = allRecent.filter((e) => qpHidden.has(e.text.toLowerCase())).length;
if (!recent.length && !hiddenInUse) {
qp.innerHTML = '';
return;
}
const itemsHtml = recent
.map((e) => {
return (
`<button class="qp-item" data-text="${escHtml(e.text)}" data-tag="${e.tag}">` +
`<span class="qp-item-text">${escHtml(e.text)}</span>` +
`<span class="qp-remove" data-text="${escHtml(e.text)}" title="remove from recent tasks">×</span>` +
`</button>`
);
})
.join('');
const restoreHtml = hiddenInUse
? `<button class="qp-restore" id="qpRestore" title="show all hidden tasks again">restore ${hiddenInUse} hidden</button>`
: '';
qp.innerHTML = `<div class="qp-wrap"><div class="qp-label">recent tasks</div><div class="qp-list">${itemsHtml}${restoreHtml}</div></div>`;
// Click pill body — fill capture input (only if click wasn't on the ✕)
qp.querySelectorAll('.qp-item').forEach((btn) => {
btn.addEventListener('click', (e) => {
if (e.target.closest('.qp-remove')) return;
document.getElementById('captureInput').value = btn.dataset.text;
selectedTag = btn.dataset.tag;
renderTagRow();
document.getElementById('captureInput').focus();
});
});
// Click ✕ — hide from recent list
qp.querySelectorAll('.qp-remove').forEach((x) => {
x.addEventListener('click', (e) => {
e.stopPropagation();
qpHidden.add(x.dataset.text.toLowerCase());
saveQpHidden();
renderQuickPick();
});
});
// Restore all hidden
const restoreBtn = document.getElementById('qpRestore');
if (restoreBtn) {
restoreBtn.addEventListener('click', () => {
qpHidden.clear();
saveQpHidden();
renderQuickPick();
});
}
}
/**
* Renders the time-tracking bar chart for the currently viewed day.
* Decorates the active timer's entry with a synthetic `tsEnd` so live time
* appears in real-time. Respects `chartMode` ('task' | 'category').
* @param {Array<Object>} list - The array of log entries to chart.
*/
function renderChart(list) {
const el = document.getElementById('chart');
if (!el) return;
// Decorate the active timer's entry with a synthetic tsEnd so its accumulated
// time appears in the chart in (near) real time — not just after the timer stops.
// Re-runs naturally on every render; a 15-min interval also forces a refresh.
const decorated = (list || []).map((e) => {
if (activeTimer && e.id === activeTimer.entryId && !e.tsEnd) {
const liveEnd = activeTimer.paused
? e.ts + (activeTimer.accumulatedMs || 0)
: Math.max(Date.now(), activeTimer.startTs || e.ts);
return Object.assign({}, e, { tsEnd: liveEnd, _live: true });
}
return e;
});
const timed = decorated.filter((e) => e.tsEnd && e.tsEnd > e.ts);
const toggleHtml = `<div class="chart-toggle">
<button class="chart-tog${chartMode === 'task' ? ' active' : ''}" data-mode="task">by task</button>
<button class="chart-tog${chartMode === 'category' ? ' active' : ''}" data-mode="category">by epic</button>
</div>`;
if (!timed.length) {
el.innerHTML = `<div class="chart-section"><div class="chart-header"><span class="chart-title">time tracked</span>${toggleHtml}</div><div class="chart-body"><div class="chart-empty">add end times to entries to see the chart</div></div></div>`;
el.querySelectorAll('.chart-tog').forEach((b) =>
b.addEventListener('click', () => {
chartMode = b.dataset.mode;
renderChart(list);
})
);
return;
}
const totals = {},
meta = {},
liveKeys = new Set(),
billCounts = {};
function tallyBill(key, e) {
if (!billCounts[key]) billCounts[key] = { bill: 0, nonBill: 0 };
if (isEntryBillable(e)) billCounts[key].bill++;
else billCounts[key].nonBill++;
}
if (chartMode === 'task') {
timed.forEach((e) => {
const key = e.text.toLowerCase();
totals[key] = (totals[key] || 0) + Math.max(0, e.tsEnd - e.ts);
if (!meta[key]) meta[key] = { label: e.text, color: getCatColor(e.tag) };
if (e._live) liveKeys.add(key);
tallyBill(key, e);
});
} else {
timed.forEach((e) => {
const key = e.tag || 'other';
totals[key] = (totals[key] || 0) + Math.max(0, e.tsEnd - e.ts);
if (!meta[key]) meta[key] = { label: getCatLabel(key), color: getCatColor(key) };
if (e._live) liveKeys.add(key);
tallyBill(key, e);
});
}
// Per-row billable icon: 💰 if all billable, 💸 if all non-billable, ⚖️ if mixed
function billIcon(key) {
const c = billCounts[key];
if (!c) return '';
if (c.bill && c.nonBill)
return '<span class="chart-bill" title="mixed billable/non-billable">⚖️</span>';
if (c.bill) return '<span class="chart-bill" title="billable">💰</span>';
if (c.nonBill) return '<span class="chart-bill" title="non-billable">💸</span>';
return '';
}
const sorted = Object.keys(totals).sort((a, b) => totals[b] - totals[a]);
const maxMs = totals[sorted[0]];
const grandTotal = Object.values(totals).reduce((a, b) => a + b, 0);
const rows = sorted
.map((key) => {
const ms = totals[key],
pct = Math.round((ms / maxMs) * 100);
const dur = fmtDur(ms);
const { label, color } = meta[key];
const live = liveKeys.has(key) ? ' chart-row-live' : '';
const liveDot = liveKeys.has(key)
? '<span class="chart-live-dot" title="currently being tracked">●</span>'
: '';
return `<div class="chart-row${live}">
<span class="chart-label" title="${escHtml(label)}">${liveDot}${escHtml(label)}</span>
<div class="chart-track"><div class="chart-bar" style="width:${pct}%;background:${safeCssColor(color)}"></div></div>
${billIcon(key)}
<span class="chart-dur">${dur}</span>
</div>`;
})
.join('');
const totalDur = fmtDur(grandTotal);
const billMs = timed.filter((e) => isEntryBillable(e)).reduce((s, e) => s + (e.tsEnd - e.ts), 0);
const nonBillMs = timed.reduce((s, e) => s + (e.tsEnd - e.ts), 0) - billMs;
const title = chartMode === 'task' ? 'time by task' : 'time by epic';
el.innerHTML = `<div class="chart-section"><div class="chart-header"><span class="chart-title">${title}</span>${toggleHtml}</div><div class="chart-body">${rows}<div class="chart-total">total tracked: <span>${totalDur}</span></div>${billMs > 0 || nonBillMs > 0 ? `<div class="chart-total">💰 billable: <span>${fmtDur(billMs)}</span></div><div class="chart-total">💸 non-billable: <span>${fmtDur(nonBillMs)}</span></div>` : ''}</div></div>`;
el.querySelectorAll('.chart-tog').forEach((b) =>
b.addEventListener('click', () => {
chartMode = b.dataset.mode;
renderChart(list);
})
);
}
/* ── Helpers ── */
/** Closes every open inline time-editor panel and restores the display spans. */
function closeAllEditors() {
document.querySelectorAll('.etime-editor.open').forEach((el) => el.classList.remove('open'));
document.querySelectorAll('.etime-display').forEach((el) => (el.style.display = ''));
}
/**
* Converts a Unix timestamp (ms) to an HH:MM string suitable for an
* `<input type="time">` value.
* @param {number} ts - Unix timestamp in milliseconds.
* @returns {string} Local time formatted as "HH:MM".
*/
function toTimeInput(ts) {
const d = new Date(ts);
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
}
/**
* Replaces the hours/minutes of a base timestamp with values parsed from a
* "HH:MM" string, returning the resulting timestamp in milliseconds.
* @param {number} baseTsMs - Base Unix timestamp (ms) that supplies the date.
* @param {string} timeStr - Time string in "HH:MM" format.
* @returns {number} New Unix timestamp (ms) with the updated time.
*/
function applyTime(baseTsMs, timeStr) {
const d = new Date(baseTsMs),
[hh, mm] = timeStr.split(':').map(Number);
d.setHours(hh, mm, 0, 0);
return d.getTime();
}
/**
* Builds an HTML `<span class="etime-dur">` containing the human-readable
* duration between two timestamps. Returns an empty string if the duration
* is zero or negative.
* @param {number} tsStart - Start Unix timestamp (ms).
* @param {number} tsEnd - End Unix timestamp (ms).
* @returns {string} HTML string, or '' if duration ≤ 0.
*/
function durLabel(tsStart, tsEnd) {
const mins = Math.round((tsEnd - tsStart) / 60000);
if (mins <= 0) return '';
const h = Math.floor(mins / 60),
m = mins % 60;
return `<span class="etime-dur">${h > 0 ? (m > 0 ? `${h}h ${m}m` : `${h}h`) : `${m}m`}</span>`;
}