/* ── Dev changelog ── */
/**
* Merges the hardcoded {@link DEV_CHANGES} array into the persisted dev log in
* localStorage, adding only entries whose `id` is not already stored.
* Maintains chronological sort order by `id`.
*/
function mergeDevLog() {
try {
const stored = JSON.parse(localStorage.getItem(STORE_DEV_LOG) || '[]');
const storedIds = new Set(stored.map((change) => change.id));
const newEntries = DEV_CHANGES.filter((change) => !storedIds.has(change.id));
if (newEntries.length) {
const merged = [...stored, ...newEntries].sort((a, b) => a.id.localeCompare(b.id));
localStorage.setItem(STORE_DEV_LOG, JSON.stringify(merged));
}
} catch (err) {
wlLog.warn('mergeDevChanges: failed to merge dev changelog entries', err);
}
}
/** Label markup each copy button had before its first "Copied!" flash. */
const copyButtonOriginalLabels = new WeakMap();
/**
* Briefly replaces a copy button's label with "Copied!", then restores the
* original markup (emoji span included). The original is remembered on first
* use, not read at each click: a second click inside the flash would otherwise
* capture "Copied!" as the label to restore, leaving the button stuck.
* @param {HTMLElement} button - The copy button to flash.
* @param {number} [durationMs] - How long "Copied!" stays visible.
* @returns {void}
*/
function flashCopiedLabel(button, durationMs = 2000) {
if (!copyButtonOriginalLabels.has(button)) copyButtonOriginalLabels.set(button, button.innerHTML);
button.textContent = 'Copied!';
setTimeout(() => (button.innerHTML = copyButtonOriginalLabels.get(button)), durationMs);
}
/**
* Opens the end-of-day modal for the day in view: auto-exports the time log and
* JSON backup, saves that day's EOD timestamp, populates handoff notes for its
* unfinished tasks, renders its dev changelog entries, and lists the test areas
* to review. Viewing an earlier day ends that day, not the current one.
*/
function openEodModal() {
const viewDay = getViewDate();
const dayKey = dk(viewDay);
const isViewingToday = dayKey === dk(new Date());
const dateStr = viewDay.toLocaleDateString('en', {
weekday: 'long',
month: 'long',
day: 'numeric',
});
// Auto-export
exportTxt();
exportBackup();
localStorage.setItem('wl_last_export', dk(new Date()));
// Save EOD timestamp against the day in view. An earlier day is stamped with
// its last entry's end (or the end of that day) so it does not claim to have
// ended today.
const allEntries = getEntries();
if (!getEodTs(viewDay)) {
const eodTs = resolveEodTimestamp(
viewDay,
allEntries.filter((entry) => entry.date === dayKey && entry.tsEnd > entry.ts),
Date.now()
);
localStorage.setItem(eodKey(viewDay), String(eodTs));
}
wlLog.info(`openEodModal: ending ${isViewingToday ? 'today' : 'earlier day'} ${dayKey}`);
renderEodBtn();
renderEodReminder();
// Note: portable deploy is triggered by the "Done — close" button, NOT here,
// so the JSON + .txt have time to flush to disk before the build script reads them.
document.getElementById('eodExportStatus').innerHTML =
`<div class="eod-exported">✅ Time log (.txt) and backup (.json) exported automatically</div>`;
document.getElementById('eodSubtitle').textContent = dateStr;
// Notes for the next day — only tasks that were actually worked on the viewed day
const unfinishedTasks = selectUnfinishedWorkedTasks(dayKey, allEntries, getPlanTasks());
let handoffNotes = {};
try {
handoffNotes = parseHandoffNotes(localStorage.getItem('wl_handoff'));
} catch (err) {
// Silently fall back to empty object — existing notes are unavailable but EOD modal still works
wlLog.warn('openEodModal: failed to parse wl_handoff', err);
}
const taskNotesEl = document.getElementById('eodTaskNotes');
if (unfinishedTasks.length) {
const statusLabel = {
todo: 'to do',
inprogress: 'in progress',
pending: 'pending',
blocked: 'blocked',
};
taskNotesEl.innerHTML = unfinishedTasks
.map(
(task) =>
`<div class="eod-task-note-row">
<span class="eod-task-note-label" title="${escHtml(task.text)}">${task.emoji ? escHtml(task.emoji) + ' ' : ''}${escHtml(task.text)}</span>
<span class="eod-task-note-status ${task.status || 'todo'}">${statusLabel[task.status || 'todo'] || task.status}</span>
<input class="eod-task-note-input" data-task="${escHtml(task.text.toLowerCase().trim())}"
value="${escHtml(handoffNotes[task.text.toLowerCase().trim()] || '')}"
placeholder="where to continue…" />
</div>`
)
.join('');
} else {
taskNotesEl.innerHTML = `<div class="eod-empty">no tasks worked on this day — or all done 🎉</div>`;
}
// The viewed day's dev changes
let allLog = [];
try {
allLog = JSON.parse(localStorage.getItem(STORE_DEV_LOG) || '[]');
} catch (err) {
wlLog.warn('openEodModal: failed to parse dev changelog from localStorage', err);
}
const viewedDayChanges = selectDevChangesForDay(allLog, dayKey);
const changesEl = document.getElementById('eodChanges');
if (viewedDayChanges.length) {
changesEl.innerHTML = viewedDayChanges
.map(
(change) =>
`<div class="eod-change">
<span class="eod-change-desc">${escHtml(change.desc)}</span>
<span class="eod-change-areas">${change.areas.length ? 'Test ' + escHtml(change.areas.join(', ')) : '—'}</span>
</div>`
)
.join('');
} else {
changesEl.innerHTML = `<div class="eod-empty">No code changes logged this day</div>`;
}
// Affected test areas (deduplicated)
const affectedAreas = [...new Set(viewedDayChanges.flatMap((change) => change.areas))].sort(
(a, b) => a - b
);
const areasEl = document.getElementById('eodTestAreas');
if (affectedAreas.length) {
areasEl.innerHTML = affectedAreas
.map((area) => {
// eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
const areaName = TEST_AREA_NAMES[area] || 'Unknown';
return `<div class="eod-test-area">
<span class="eod-test-num">#${escHtml(String(area))}</span>
<span>${escHtml(areaName)}</span>
</div>`;
})
.join('');
} else {
areasEl.innerHTML = `<div class="eod-empty">No test areas flagged for review</div>`;
}
// Copy to clipboard
document.getElementById('eodCopyBtn').onclick = () => {
const lines = [
`End of day: ${dateStr}`,
'',
'Changes implemented:',
...viewedDayChanges.map(
(change) =>
` - ${change.desc}${change.areas.length ? ' (Test ' + change.areas.join(', ') + ')' : ''}`
),
'',
'Test areas to review:',
// eslint-disable-next-line security/detect-object-injection -- key is an internal app-state value (app data), not from untrusted external input
...affectedAreas.map((area) => ` - Test ${area}: ${TEST_AREA_NAMES[area]}`),
];
navigator.clipboard.writeText(lines.join('\n')).then(() => {
flashCopiedLabel(document.getElementById('eodCopyBtn'));
});
};
renderEodTimesheet(dayKey);
document.getElementById('eodOverlay').classList.add('show');
}
document.getElementById('eodBtn').addEventListener('click', () => {
const ready = confirm(
'📎 Before closing the day:\n\n' +
"Have you shared work-log.html with Claude to log today's changes?\n\n" +
'OK — yes, changes are logged, continue\n' +
'Cancel — not yet, go back'
);
if (ready) openEodModal();
});
/**
* Parses the stored `wl_handoff` value into a prototype-less map of task key to
* note. A prototype-less object keeps keys such as `__proto__` and
* `constructor` as ordinary own properties, so task names cannot reach
* Object.prototype.
* @param {string|null} raw - Raw localStorage value (may be null or malformed JSON).
* @returns {Object<string, string>} Map of task key to note; empty if the value is
* missing or is not a plain JSON object (e.g. an array, string or null).
* @throws {SyntaxError} If `raw` is non-empty and not valid JSON.
*/
function parseHandoffNotes(raw) {
const parsed = JSON.parse(raw || '{}');
const isPlainObject = parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed);
return Object.assign(Object.create(null), isPlainObject ? parsed : {});
}
/**
* Reads all handoff-note inputs in the EOD modal and persists their values to
* the `wl_handoff` localStorage key. Empty values are removed from the map.
*/
function saveEodHandoffNotes() {
try {
const notes = parseHandoffNotes(localStorage.getItem('wl_handoff'));
document.querySelectorAll('.eod-task-note-input').forEach((inp) => {
const key = inp.dataset.task;
const val = inp.value.trim();
// eslint-disable-next-line security/detect-object-injection -- key comes from a DOM data attribute (task text), but notes has no prototype, so any key is an own property
if (val) notes[key] = val;
// eslint-disable-next-line security/detect-object-injection -- same as above: prototype-less map, `delete` only removes an own key
else delete notes[key];
});
localStorage.setItem('wl_handoff', JSON.stringify(notes));
} catch (err) {
wlLog.warn('saveEodHandoffNotes: failed to persist handoff notes to localStorage', err);
}
}
document.getElementById('expiryBtn').addEventListener('click', openExpiryModal);
document.getElementById('expirySave').addEventListener('click', saveExpiryDates);
document.getElementById('expiryCancel').addEventListener('click', () => {
document.getElementById('expiryOverlay').classList.remove('show');
});
document.getElementById('expiryOverlay').addEventListener('click', (event) => {
if (event.target === document.getElementById('expiryOverlay'))
document.getElementById('expiryOverlay').classList.remove('show');
});
document.getElementById('expiryTextarea').addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.stopPropagation();
} // allow newlines
if (event.key === 'Escape') document.getElementById('expiryOverlay').classList.remove('show');
});
document.getElementById('eodClose').addEventListener('click', () => {
saveEodHandoffNotes();
// Trigger portable deploy now — the export ran when the modal opened, so the
// file system has had a few seconds to flush JSON + .txt to OneDrive before
// the build script reads them.
triggerPortableDeploy();
document.getElementById('eodOverlay').classList.remove('show');
renderPlan();
});
/**
* Fire-and-forget portable deploy: calls `POST /api/portable-deploy` to trigger
* the PowerShell build script, then displays a transient top-right toast with the
* result (success, failure, or server unreachable). Never throws.
*/
function triggerPortableDeploy() {
const toast = document.createElement('div');
toast.className = 'wl-toast wl-toast-info';
toast.textContent = '⏳ Deploying portable build…';
document.body.appendChild(toast);
const setToast = (cls, text, lifetimeMs = 5000) => {
toast.className = 'wl-toast ' + cls;
toast.textContent = text;
setTimeout(() => toast.remove(), lifetimeMs);
};
(async () => {
let res;
try {
res = await fetch('/api/portable-deploy', { method: 'POST' });
} catch (err) {
setToast('wl-toast-err', '⚠ Portable deploy skipped: PS server unreachable');
return;
}
const bodyText = await res.text().catch(() => '');
let data = null;
if (bodyText) {
try {
data = JSON.parse(bodyText);
} catch {}
}
if (res.status === 404) {
setToast(
'wl-toast-err',
'⚠ Portable deploy unavailable: restart PS server (.\\launch.bat) to pick up updated start-server.ps1'
);
return;
}
if (res.status === 503) {
setToast('wl-toast-err', '⚠ Portable deploy skipped: PS server not running');
return;
}
if (res.ok && data && data.ok) {
const s = (data.durationMs / 1000).toFixed(1);
setToast('wl-toast-ok', `📦 Portable deployed in ${s}s`, 4000);
return;
}
const msg =
data && (data.error || data.output)
? String(data.error || data.output).slice(0, 200)
: bodyText
? bodyText.slice(0, 200)
: `HTTP ${res.status} empty body`;
setToast('wl-toast-err', `⚠ Portable deploy failed: ${msg}`, 8000);
})();
}
document.getElementById('eodOverlay').addEventListener('click', (event) => {
if (event.target === document.getElementById('eodOverlay')) {
saveEodHandoffNotes();
document.getElementById('eodOverlay').classList.remove('show');
renderPlan();
}
});