Harden auto-sync history row rendering

Render playlist pipeline history rows with DOM construction instead of per-card HTML strings so malformed payload values cannot collapse the row into an empty bordered shell. Add a visible per-row render fallback for bad history records while preserving the expandable detail panel.
This commit is contained in:
Broque Thomas 2026-05-25 09:06:43 -07:00
parent 4aec5584e1
commit 8f72cc3113

View file

@ -245,6 +245,7 @@ function renderAutoSyncScheduleModal() {
<div class="auto-sync-tab-panel ${historyActive ? 'active' : ''}" id="auto-sync-history-panel">${historyPanel}</div> <div class="auto-sync-tab-panel ${historyActive ? 'active' : ''}" id="auto-sync-history-panel">${historyPanel}</div>
</div> </div>
`; `;
populateAutoSyncHistoryList(overlay);
bindAutoSyncHistoryCardInteractions(overlay); bindAutoSyncHistoryCardInteractions(overlay);
} }
@ -439,13 +440,61 @@ function renderAutoSyncHistoryPanel(history, total) {
<button onclick="refreshAutoSyncScheduleModal()">Refresh</button> <button onclick="refreshAutoSyncScheduleModal()">Refresh</button>
</div> </div>
<div class="auto-sync-history-list"> <div class="auto-sync-history-list">
${history.map((entry, index) => autoSyncHistoryEntryHtml(entry, index)).join('')} <div class="auto-sync-history-loading">Loading run history...</div>
${total > history.length ? `<div class="auto-sync-history-total">Showing ${history.length} of ${total} runs</div>` : ''}
</div> </div>
`; `;
} }
function autoSyncHistoryEntryHtml(entry, index = 0) { function populateAutoSyncHistoryList(root = document) {
const list = root.querySelector('.auto-sync-history-list');
if (!list) return;
const history = _autoSyncScheduleState.runHistory || [];
const total = _autoSyncScheduleState.runHistoryTotal || 0;
list.innerHTML = '';
history.forEach((entry, index) => {
try {
list.appendChild(createAutoSyncHistoryEntryElement(entry, index));
} catch (err) {
list.appendChild(createAutoSyncHistoryErrorElement(entry, index, err));
}
});
if (total > history.length) {
const totalEl = document.createElement('div');
totalEl.className = 'auto-sync-history-total';
totalEl.textContent = `Showing ${history.length} of ${total} runs`;
list.appendChild(totalEl);
}
}
function createAutoSyncHistoryErrorElement(entry, index, err) {
const card = document.createElement('article');
card.className = 'auto-sync-history-entry auto-sync-history-entry-error';
const row = document.createElement('div');
row.className = 'auto-sync-history-row';
const head = document.createElement('div');
head.className = 'auto-sync-history-card-head';
const titleBlock = document.createElement('div');
titleBlock.className = 'auto-sync-history-title-block';
const title = document.createElement('div');
title.className = 'auto-sync-history-title-row';
const dot = document.createElement('span');
dot.className = 'auto-sync-card-status-dot disabled';
const name = document.createElement('strong');
name.textContent = entry?.playlist_name || `Run #${entry?.id || index + 1}`;
const badge = document.createElement('span');
badge.className = 'auto-sync-history-status error';
badge.textContent = 'Render error';
title.append(dot, name, badge);
const summary = document.createElement('small');
summary.textContent = err?.message || 'This run history row could not be rendered.';
titleBlock.append(title, summary);
head.appendChild(titleBlock);
row.appendChild(head);
card.appendChild(row);
return card;
}
function createAutoSyncHistoryEntryElement(entry, index = 0) {
entry = autoSyncNormalizeHistoryEntry(entry, index); entry = autoSyncNormalizeHistoryEntry(entry, index);
const status = entry.status || 'completed'; const status = entry.status || 'completed';
const before = entry.before_json || {}; const before = entry.before_json || {};
@ -460,46 +509,98 @@ function autoSyncHistoryEntryHtml(entry, index = 0) {
const entryId = `auto-sync-history-${entry.id}`; const entryId = `auto-sync-history-${entry.id}`;
const playlistName = entry.playlist_name || after.name || before.name || `Playlist #${entry.playlist_id || 'unknown'}`; const playlistName = entry.playlist_name || after.name || before.name || `Playlist #${entry.playlist_id || 'unknown'}`;
const summary = entry.summary || autoSyncHistoryFallbackSummary(before, after, status); const summary = entry.summary || autoSyncHistoryFallbackSummary(before, after, status);
return `
<article class="auto-sync-history-entry" id="${entryId}-card" data-history-entry="${entryId}"> const card = document.createElement('article');
<div class="auto-sync-history-row" role="button" tabindex="0" aria-expanded="false" aria-controls="${entryId}" data-history-toggle="${entryId}"> card.className = 'auto-sync-history-entry';
<div class="auto-sync-history-card-head"> card.id = `${entryId}-card`;
<div class="auto-sync-history-title-block"> card.dataset.historyEntry = entryId;
<div class="auto-sync-history-title-row">
<span class="auto-sync-card-status-dot ${autoSyncHistoryStatusClass(status)}"></span> const row = document.createElement('div');
<strong>${_esc(playlistName)}</strong> row.className = 'auto-sync-history-row';
<span class="auto-sync-history-status ${_escAttr(status)}">${_esc(autoSyncHistoryStatusLabel(status))}</span> row.setAttribute('role', 'button');
</div> row.tabIndex = 0;
<small>${_esc(summary)}</small> row.setAttribute('aria-expanded', 'false');
</div> row.setAttribute('aria-controls', entryId);
<div class="auto-sync-history-meta"> row.dataset.historyToggle = entryId;
${started ? `<span>${_esc(started)}</span>` : ''}
${duration ? `<span>${_esc(duration)}</span>` : ''} const head = document.createElement('div');
<span>${_esc(entry.trigger_source || 'pipeline')}</span> head.className = 'auto-sync-history-card-head';
<button type="button" class="auto-sync-history-expand-label" data-history-toggle-button="${entryId}">View details</button> const titleBlock = document.createElement('div');
</div> titleBlock.className = 'auto-sync-history-title-block';
</div> const titleRow = document.createElement('div');
<div class="auto-sync-card-flow"> titleRow.className = 'auto-sync-history-title-row';
<span class="flow-trigger">${_esc(entry.trigger_source || 'pipeline')}</span> const dot = document.createElement('span');
<span class="flow-arrow">&rarr;</span> dot.className = `auto-sync-card-status-dot ${autoSyncHistoryStatusClass(status)}`;
<span class="flow-action">Refresh</span> const title = document.createElement('strong');
<span class="flow-arrow">&rarr;</span> title.textContent = playlistName;
<span class="flow-action">Discover</span> const statusBadge = document.createElement('span');
<span class="flow-arrow">&rarr;</span> statusBadge.className = `auto-sync-history-status ${status}`;
<span class="flow-notify">Sync + wishlist</span> statusBadge.textContent = autoSyncHistoryStatusLabel(status);
</div> titleRow.append(dot, title, statusBadge);
<div class="auto-sync-history-preview"> const summaryEl = document.createElement('small');
${autoSyncHistoryPreviewPill('Tracks', before.track_count, after.track_count, trackDelta)} summaryEl.textContent = summary;
${autoSyncHistoryPreviewPill('Discovered', before.discovered_count, after.discovered_count, discoveredDelta)} titleBlock.append(titleRow, summaryEl);
${autoSyncHistoryPreviewPill('Wishlisted', before.wishlisted_count, after.wishlisted_count, wishlistDelta)}
${autoSyncHistoryPreviewPill('Library', before.in_library_count, after.in_library_count, libraryDelta)} const meta = document.createElement('div');
</div> meta.className = 'auto-sync-history-meta';
</div> [started, duration, entry.trigger_source || 'pipeline'].filter(Boolean).forEach(value => {
<div id="${entryId}" class="auto-sync-history-detail"> const span = document.createElement('span');
${autoSyncHistoryDetailHtml(entry, before, after, result, { trackDelta, discoveredDelta, wishlistDelta, libraryDelta })} span.textContent = value;
</div> meta.appendChild(span);
</article> });
`; const expand = document.createElement('button');
expand.type = 'button';
expand.className = 'auto-sync-history-expand-label';
expand.dataset.historyToggleButton = entryId;
expand.textContent = 'View details';
meta.appendChild(expand);
head.append(titleBlock, meta);
const flow = document.createElement('div');
flow.className = 'auto-sync-card-flow';
autoSyncAppendFlowChip(flow, entry.trigger_source || 'pipeline', 'flow-trigger');
autoSyncAppendFlowArrow(flow);
autoSyncAppendFlowChip(flow, 'Refresh', 'flow-action');
autoSyncAppendFlowArrow(flow);
autoSyncAppendFlowChip(flow, 'Discover', 'flow-action');
autoSyncAppendFlowArrow(flow);
autoSyncAppendFlowChip(flow, 'Sync + wishlist', 'flow-notify');
const preview = document.createElement('div');
preview.className = 'auto-sync-history-preview';
[
['Tracks', before.track_count, after.track_count, trackDelta],
['Discovered', before.discovered_count, after.discovered_count, discoveredDelta],
['Wishlisted', before.wishlisted_count, after.wishlisted_count, wishlistDelta],
['Library', before.in_library_count, after.in_library_count, libraryDelta],
].forEach(([label, beforeValue, afterValue, delta]) => {
const pill = document.createElement('span');
pill.textContent = autoSyncHistoryPreviewText(label, beforeValue, afterValue, delta);
preview.appendChild(pill);
});
const detail = document.createElement('div');
detail.id = entryId;
detail.className = 'auto-sync-history-detail';
detail.innerHTML = autoSyncHistoryDetailHtml(entry, before, after, result, { trackDelta, discoveredDelta, wishlistDelta, libraryDelta });
row.append(head, flow, preview);
card.append(row, detail);
return card;
}
function autoSyncAppendFlowChip(parent, text, className) {
const span = document.createElement('span');
span.className = className;
span.textContent = text;
parent.appendChild(span);
}
function autoSyncAppendFlowArrow(parent) {
const span = document.createElement('span');
span.className = 'flow-arrow';
span.textContent = '->';
parent.appendChild(span);
} }
function autoSyncNormalizeHistoryEntry(entry, index) { function autoSyncNormalizeHistoryEntry(entry, index) {
@ -613,10 +714,14 @@ function autoSyncHistoryStatHtml(label, before, after, delta) {
} }
function autoSyncHistoryPreviewPill(label, before, after, delta) { function autoSyncHistoryPreviewPill(label, before, after, delta) {
return `<span>${_esc(autoSyncHistoryPreviewText(label, before, after, delta))}</span>`;
}
function autoSyncHistoryPreviewText(label, before, after, delta) {
const beforeValue = parseInt(before, 10) || 0; const beforeValue = parseInt(before, 10) || 0;
const afterValue = parseInt(after, 10) || 0; const afterValue = parseInt(after, 10) || 0;
const deltaText = delta ? ` ${delta > 0 ? '+' : ''}${delta}` : ''; const deltaText = delta ? ` ${delta > 0 ? '+' : ''}${delta}` : '';
return `<span>${_esc(label)} ${beforeValue}->${afterValue}${_esc(deltaText)}</span>`; return `${label} ${beforeValue}->${afterValue}${deltaText}`;
} }
function autoSyncHistoryResultPill(label, value) { function autoSyncHistoryResultPill(label, value) {