/**
* Change log tracking and rendering
* Tracks all cell changes in the inventory table
*/
/**
* Records a change to the change log and persists it
* @param {string} itemName - Name of the inventory item
* @param {string} field - Field that was changed
* @param {any} oldValue - Previous value
* @param {any} newValue - New value
* @param {number} idx - Index of item in inventory array
*/
const logChange = (itemName, field, oldValue, newValue, idx) => {
changeLog.push({
timestamp: Date.now(),
itemName,
field,
oldValue,
newValue,
idx,
undone: false,
});
localStorage.setItem('changeLog', JSON.stringify(changeLog));
};
/**
* Compares two item objects and logs any differences
* @param {Object} oldItem - Original item values
* @param {Object} newItem - Updated item values
*/
const logItemChanges = (oldItem, newItem) => {
const fields = [
'date',
'type',
'metal',
'name',
'qty',
'weight',
'price',
'marketValue',
'purchaseLocation',
'notes',
];
fields.forEach((field) => {
if (oldItem[field] !== newItem[field]) {
const idx = inventory.indexOf(newItem);
logChange(newItem.name, field, oldItem[field], newItem[field], idx);
}
});
};
/**
* Renders the change log table with all entries
*/
const renderChangeLog = () => {
const rows = [...changeLog]
.slice()
.reverse()
.map((entry, i) => {
const globalIndex = changeLog.length - 1 - i;
const actionLabel = entry.undone ? 'Redo' : 'Undo';
// Friendly display for price history deletions (STAK-109)
let displayField = sanitizeHtml(entry.field);
let displayOld = sanitizeHtml(String(entry.oldValue));
let displayNew = sanitizeHtml(String(entry.newValue));
// Format raw JSON snapshots into human-readable summaries (UX-001)
if ((entry.field === 'Deleted' || entry.field === 'Added') && entry.oldValue) {
try {
const snap = typeof entry.oldValue === 'string' ? JSON.parse(entry.oldValue) : entry.oldValue;
if (snap && typeof snap === 'object' && snap.name) {
const fmtFn = typeof formatCurrency === 'function' ? formatCurrency : (v) => '$' + Number(v).toFixed(2);
const parts = [snap.metal, snap.type, snap.name];
if (snap.weight) parts.push(snap.weight + (snap.weightUnit === 'g' ? 'g' : ' oz'));
if (snap.price) parts.push(fmtFn(snap.price));
displayOld = sanitizeHtml(parts.filter(Boolean).join(' \u00B7 '));
}
} catch { /* keep original */ }
}
let rowClick = `onclick="editFromChangeLog(${entry.idx}, ${globalIndex})"`;
if (entry.field === 'priceHistoryDelete') {
displayField = 'Price Entry Deleted';
try {
const d = JSON.parse(entry.oldValue);
const fmtFn = typeof formatCurrency === 'function' ? formatCurrency : (v) => '$' + Number(v).toFixed(2);
displayOld = `Retail: ${sanitizeHtml(fmtFn(d.entry.retail))}`;
} catch { displayOld = '(price entry)'; }
displayNew = entry.undone ? 'Restored' : 'Deleted';
rowClick = ''; // No item to navigate to
}
return `
<tr ${rowClick}>
<td title="${formatTimestamp(entry.timestamp)}">${formatTimestamp(entry.timestamp)}</td>
<td title="${sanitizeHtml(entry.itemName)}">${sanitizeHtml(entry.itemName)}</td>
<td title="${displayField}">${displayField}</td>
<td title="${displayOld}">${displayOld}</td>
<td title="${displayNew}">${displayNew}</td>
<td class="action-cell"><button class="btn action-btn" style="margin:1px;" onclick="event.stopPropagation(); toggleChange(${globalIndex})">${actionLabel}</button></td>
</tr>`;
});
const html = rows.join('');
// Populate both the modal table and the settings panel table
const modalBody = document.querySelector('#changeLogTable tbody');
// nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml, javascript.browser.security.insecure-document-method.insecure-document-method
if (modalBody) modalBody.innerHTML = html;
const settingsBody = document.querySelector('#settingsChangeLogTable tbody');
// nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml, javascript.browser.security.insecure-document-method.insecure-document-method
if (settingsBody) settingsBody.innerHTML = html;
};
/**
* Toggles a logged change between undone and redone states
* @param {number} logIdx - Index of change entry in changeLog array
*/
const toggleChange = (logIdx) => {
const entry = changeLog[logIdx];
if (!entry) return;
// Price history delete — undo restores the entry, redo re-deletes it (STAK-109)
if (entry.field === 'priceHistoryDelete') {
const deleted = JSON.parse(entry.oldValue);
if (entry.undone) {
// Redo: re-delete the entry
if (itemPriceHistory[deleted.uuid]) {
itemPriceHistory[deleted.uuid] = itemPriceHistory[deleted.uuid]
.filter(e => e.ts !== deleted.entry.ts);
if (itemPriceHistory[deleted.uuid].length === 0) {
delete itemPriceHistory[deleted.uuid];
}
}
entry.undone = false;
} else {
// Undo: restore the deleted entry
if (!itemPriceHistory[deleted.uuid]) itemPriceHistory[deleted.uuid] = [];
itemPriceHistory[deleted.uuid].push(deleted.entry);
itemPriceHistory[deleted.uuid].sort((a, b) => a.ts - b.ts);
entry.undone = true;
}
if (typeof saveItemPriceHistory === 'function') saveItemPriceHistory();
if (typeof renderItemPriceHistoryTable === 'function') renderItemPriceHistoryTable();
if (typeof renderItemPriceHistoryModalTable === 'function') renderItemPriceHistoryModalTable();
renderChangeLog();
localStorage.setItem('changeLog', JSON.stringify(changeLog));
return;
}
if (entry.field === 'Deleted') {
if (entry.undone) {
const removed = inventory.splice(entry.idx, 1)[0];
if (removed && removed.serial) {
delete catalogMap[removed.serial];
}
entry.undone = false;
} else {
const restored = JSON.parse(entry.oldValue || '{}');
inventory.splice(entry.idx, 0, restored);
if (restored.serial) {
catalogMap[restored.serial] = restored.numistaId || "";
}
entry.undone = true;
}
} else {
const item = inventory[entry.idx];
if (!item) return;
if (entry.undone) {
item[entry.field] = entry.newValue;
entry.undone = false;
} else {
item[entry.field] = entry.oldValue;
entry.undone = true;
}
if (item.serial) {
catalogMap[item.serial] = item.numistaId || "";
}
}
saveInventory();
renderTable();
renderChangeLog();
localStorage.setItem('changeLog', JSON.stringify(changeLog));
};
/**
* Clears all change log entries after confirmation
*/
const clearChangeLog = () => {
if (!confirm('Clear change log?')) return;
changeLog = [];
localStorage.setItem('changeLog', JSON.stringify(changeLog));
renderChangeLog();
};
window.logChange = logChange;
window.logItemChanges = logItemChanges;
window.renderChangeLog = renderChangeLog;
window.toggleChange = toggleChange;
window.clearChangeLog = clearChangeLog;
window.editFromChangeLog = (idx, logIdx) => {
const modal = document.getElementById('changeLogModal');
if (modal) {
modal.style.display = 'none';
}
document.body.style.overflow = '';
editItem(idx, logIdx);
};