Expand auto-sync run history cards

Make playlist pipeline run history cards clickable and keyboard-accessible, with expanded detail sections for summary stats, timeline, before/after snapshots, result payload fields, and future run logs. Refresh the card styling so the expanded state remains responsive inside the Auto-Sync modal.
This commit is contained in:
Broque Thomas 2026-05-25 08:24:47 -07:00
parent 60b346aa33
commit e86e74d640
2 changed files with 303 additions and 29 deletions

View file

@ -459,16 +459,9 @@ function autoSyncHistoryEntryHtml(entry, index = 0) {
const entryId = `auto-sync-history-${entry.id}`;
const playlistName = entry.playlist_name || after.name || before.name || `Playlist #${entry.playlist_id || 'unknown'}`;
const summary = entry.summary || autoSyncHistoryFallbackSummary(before, after, status);
const resultHtml = [
autoSyncHistoryResultPill('Refreshed', result.playlists_refreshed),
autoSyncHistoryResultPill('Synced', result.tracks_synced),
autoSyncHistoryResultPill('Skipped', result.sync_skipped),
autoSyncHistoryResultPill('Queued', result.wishlist_queued),
result.error ? `<span class="error">${_esc(result.error)}</span>` : '',
].filter(Boolean).join('');
return `
<article class="auto-sync-history-entry">
<div class="auto-sync-history-row" onclick="autoSyncToggleHistoryEntry('${entryId}')">
<article class="auto-sync-history-entry" id="${entryId}-card">
<div class="auto-sync-history-row" role="button" tabindex="0" aria-expanded="false" aria-controls="${entryId}" onclick="autoSyncToggleHistoryEntry('${entryId}')" onkeydown="autoSyncHistoryEntryKeydown(event, '${entryId}')">
<span class="auto-sync-card-status-dot ${autoSyncHistoryStatusClass(status)}"></span>
<div class="auto-sync-history-main">
<div class="auto-sync-history-title-row">
@ -496,19 +489,11 @@ function autoSyncHistoryEntryHtml(entry, index = 0) {
${started ? `<span>${_esc(started)}</span>` : ''}
${duration ? `<span>${_esc(duration)}</span>` : ''}
<span>${_esc(entry.trigger_source || 'pipeline')}</span>
<button type="button" onclick="event.stopPropagation(); autoSyncToggleHistoryEntry('${entryId}')">Details</button>
<span class="auto-sync-history-expand-label">Expand</span>
</div>
</div>
<div id="${entryId}" class="auto-sync-history-detail">
<div class="auto-sync-history-stats">
${autoSyncHistoryStatHtml('Tracks', before.track_count, after.track_count, trackDelta)}
${autoSyncHistoryStatHtml('Discovered', before.discovered_count, after.discovered_count, discoveredDelta)}
${autoSyncHistoryStatHtml('Wishlisted', before.wishlisted_count, after.wishlisted_count, wishlistDelta)}
${autoSyncHistoryStatHtml('In library', before.in_library_count, after.in_library_count, libraryDelta)}
</div>
<div class="auto-sync-history-result">
${resultHtml || '<span class="muted">No detailed result payload recorded for this run.</span>'}
</div>
${autoSyncHistoryDetailHtml(entry, before, after, result, { trackDelta, discoveredDelta, wishlistDelta, libraryDelta })}
</div>
</article>
`;
@ -556,7 +541,18 @@ function autoSyncHistoryFallbackSummary(before, after, status) {
function autoSyncToggleHistoryEntry(entryId) {
const el = document.getElementById(entryId);
if (el) el.classList.toggle('expanded');
const card = document.getElementById(`${entryId}-card`);
const row = card?.querySelector('.auto-sync-history-row');
if (!el) return;
const expanded = el.classList.toggle('expanded');
if (card) card.classList.toggle('expanded', expanded);
if (row) row.setAttribute('aria-expanded', expanded ? 'true' : 'false');
}
function autoSyncHistoryEntryKeydown(event, entryId) {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
autoSyncToggleHistoryEntry(entryId);
}
function autoSyncHistoryStatusLabel(status) {
@ -610,6 +606,141 @@ function autoSyncHistoryResultPill(label, value) {
return `<span>${_esc(label)}: ${_esc(String(value))}</span>`;
}
function autoSyncHistoryDetailHtml(entry, before, after, result, deltas) {
const resultPills = [
autoSyncHistoryResultPill('Refreshed', result.playlists_refreshed),
autoSyncHistoryResultPill('Discovered', result.tracks_discovered),
autoSyncHistoryResultPill('Synced', result.tracks_synced),
autoSyncHistoryResultPill('Skipped', result.sync_skipped),
autoSyncHistoryResultPill('Wishlisted', result.wishlist_queued),
autoSyncHistoryResultPill('Duration', result.duration_seconds ? autoSyncDurationLabel(result.duration_seconds) : ''),
result.error ? `<span class="error">${_esc(result.error)}</span>` : '',
].filter(Boolean).join('');
const timeline = [
['Started', autoSyncFormatDateTime(entry.started_at)],
['Finished', autoSyncFormatDateTime(entry.finished_at)],
['Duration', entry.duration_seconds ? autoSyncDurationLabel(entry.duration_seconds) : 'Not recorded'],
['Trigger', entry.trigger_source || 'pipeline'],
['Source', entry.source || after.source || before.source || 'Unknown'],
['Playlist ID', entry.playlist_id || after.playlist_id || before.playlist_id || 'Unknown'],
];
return `
<div class="auto-sync-history-detail-grid">
<section class="auto-sync-history-section">
<div class="auto-sync-history-section-title">Run Summary</div>
<div class="auto-sync-history-stats">
${autoSyncHistoryStatHtml('Tracks', before.track_count, after.track_count, deltas.trackDelta)}
${autoSyncHistoryStatHtml('Discovered', before.discovered_count, after.discovered_count, deltas.discoveredDelta)}
${autoSyncHistoryStatHtml('Wishlisted', before.wishlisted_count, after.wishlisted_count, deltas.wishlistDelta)}
${autoSyncHistoryStatHtml('In library', before.in_library_count, after.in_library_count, deltas.libraryDelta)}
</div>
<div class="auto-sync-history-result">
${resultPills || '<span class="muted">No detailed result payload recorded for this run.</span>'}
</div>
</section>
<section class="auto-sync-history-section">
<div class="auto-sync-history-section-title">Timeline</div>
<div class="auto-sync-history-facts">
${timeline.map(([label, value]) => autoSyncHistoryFactHtml(label, value)).join('')}
</div>
</section>
</div>
<div class="auto-sync-history-snapshots">
${autoSyncHistorySnapshotHtml('Before refresh', before)}
${autoSyncHistorySnapshotHtml('After pipeline', after)}
</div>
${autoSyncHistoryObjectHtml('Result payload', result, { skipPrivate: true })}
${autoSyncHistoryLogsHtml(entry.log_lines)}
`;
}
function autoSyncHistoryFactHtml(label, value) {
return `
<div>
<span>${_esc(label)}</span>
<strong>${_esc(autoSyncValueLabel(value))}</strong>
</div>
`;
}
function autoSyncHistorySnapshotHtml(title, snapshot) {
const fields = [
['Name', snapshot.name],
['Source', snapshot.source],
['Tracks', snapshot.track_count],
['Discovered', snapshot.discovered_count],
['Wishlisted', snapshot.wishlisted_count],
['In library', snapshot.in_library_count],
];
return `
<section class="auto-sync-history-section auto-sync-history-snapshot">
<div class="auto-sync-history-section-title">${_esc(title)}</div>
<div class="auto-sync-history-facts compact">
${fields.map(([label, value]) => autoSyncHistoryFactHtml(label, value)).join('')}
</div>
</section>
`;
}
function autoSyncHistoryObjectHtml(title, obj, options = {}) {
if (!obj || typeof obj !== 'object') return '';
const entries = Object.entries(obj)
.filter(([key, value]) => !(options.skipPrivate && key.startsWith('_')) && value !== undefined && value !== null && value !== '')
.slice(0, 24);
if (!entries.length) return '';
return `
<section class="auto-sync-history-section">
<div class="auto-sync-history-section-title">${_esc(title)}</div>
<div class="auto-sync-history-payload">
${entries.map(([key, value]) => `
<div>
<span>${_esc(autoSyncHumanizeKey(key))}</span>
<strong>${_esc(autoSyncValueLabel(value))}</strong>
</div>
`).join('')}
</div>
</section>
`;
}
function autoSyncHistoryLogsHtml(logLines) {
if (!Array.isArray(logLines) || !logLines.length) return '';
return `
<section class="auto-sync-history-section">
<div class="auto-sync-history-section-title">Run Log</div>
<div class="auto-sync-history-logs">
${logLines.slice(-12).map(line => {
const text = typeof line === 'string' ? line : (line.message || line.log_line || JSON.stringify(line));
const type = typeof line === 'object' ? (line.type || line.log_type || 'info') : 'info';
return `<div class="${_escAttr(type)}">${_esc(text)}</div>`;
}).join('')}
</div>
</section>
`;
}
function autoSyncFormatDateTime(value) {
if (!value) return '';
const ts = _autoParseUTC(value);
if (!Number.isFinite(ts)) return value;
return new Date(ts).toLocaleString();
}
function autoSyncHumanizeKey(key) {
return String(key || '')
.replace(/^_+/, '')
.replace(/_/g, ' ')
.replace(/\b\w/g, ch => ch.toUpperCase());
}
function autoSyncValueLabel(value) {
if (value === undefined || value === null || value === '') return 'Not recorded';
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
if (Array.isArray(value)) return value.length ? value.map(autoSyncValueLabel).join(', ') : 'None';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}
function autoSyncAutomationCardHtml(auto, playlists) {
const cfg = auto.action_config || {};
const playlistId = autoSyncPlaylistIdFromAutomation(auto);

View file

@ -12088,6 +12088,11 @@ body.helper-mode-active #dashboard-activity-feed:hover {
box-shadow: none;
}
.auto-sync-history-entry.expanded {
border-color: rgba(var(--accent-rgb), 0.26);
background: rgba(26, 26, 26, 0.98);
}
.auto-sync-history-entry:empty {
display: flex;
align-items: center;
@ -12116,6 +12121,11 @@ body.helper-mode-active #dashboard-activity-feed:hover {
cursor: pointer;
}
.auto-sync-history-row:focus-visible {
outline: 2px solid rgba(var(--accent-rgb), 0.45);
outline-offset: -3px;
}
.auto-sync-history-status {
padding: 4px 8px;
border-radius: 999px;
@ -12196,11 +12206,11 @@ body.helper-mode-active #dashboard-activity-feed:hover {
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
max-width: 190px;
}
.auto-sync-history-meta span,
.auto-sync-history-result span,
.auto-sync-history-meta button {
.auto-sync-history-result span {
padding: 4px 7px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.055);
@ -12209,32 +12219,74 @@ body.helper-mode-active #dashboard-activity-feed:hover {
font-weight: 700;
}
.auto-sync-history-meta button {
.auto-sync-history-meta .auto-sync-history-expand-label {
border: 1px solid rgba(var(--accent-rgb), 0.18);
color: rgb(var(--accent-light-rgb));
cursor: pointer;
padding-right: 20px;
position: relative;
}
.auto-sync-history-meta button:hover {
.auto-sync-history-meta .auto-sync-history-expand-label::after {
content: '';
position: absolute;
right: 8px;
top: 50%;
width: 6px;
height: 6px;
border-right: 1.5px solid currentColor;
border-bottom: 1.5px solid currentColor;
transform: translateY(-65%) rotate(45deg);
transition: transform 160ms ease;
}
.auto-sync-history-entry.expanded .auto-sync-history-expand-label::after {
transform: translateY(-30%) rotate(225deg);
}
.auto-sync-history-entry:hover .auto-sync-history-expand-label {
background: rgba(var(--accent-rgb), 0.14);
border-color: rgba(var(--accent-rgb), 0.35);
}
.auto-sync-history-detail {
display: none;
padding: 0 16px 16px;
padding: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.07);
}
.auto-sync-history-detail.expanded {
display: block;
display: flex;
flex-direction: column;
gap: 12px;
}
.auto-sync-history-detail-grid {
display: grid;
grid-template-columns: minmax(0, 1.45fr) minmax(220px, 0.8fr);
gap: 12px;
}
.auto-sync-history-section {
min-width: 0;
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.075);
border-radius: 9px;
background: rgba(255, 255, 255, 0.028);
}
.auto-sync-history-section-title {
color: rgba(255, 255, 255, 0.7);
font-size: 10px;
font-weight: 850;
letter-spacing: 0;
text-transform: uppercase;
}
.auto-sync-history-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
padding-top: 14px;
padding-top: 10px;
}
.auto-sync-history-stats div {
@ -12265,7 +12317,7 @@ body.helper-mode-active #dashboard-activity-feed:hover {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin-top: 9px;
margin-top: 10px;
}
.auto-sync-history-result span {
@ -12286,6 +12338,81 @@ body.helper-mode-active #dashboard-activity-feed:hover {
border-color: rgba(255, 255, 255, 0.08);
}
.auto-sync-history-snapshots {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.auto-sync-history-facts,
.auto-sync-history-payload {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-top: 10px;
}
.auto-sync-history-facts.compact {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.auto-sync-history-facts div,
.auto-sync-history-payload div {
min-width: 0;
padding: 9px;
border-radius: 7px;
background: rgba(255, 255, 255, 0.04);
}
.auto-sync-history-facts span,
.auto-sync-history-payload span,
.auto-sync-history-facts strong,
.auto-sync-history-payload strong {
display: block;
min-width: 0;
}
.auto-sync-history-facts span,
.auto-sync-history-payload span {
color: rgba(255, 255, 255, 0.36);
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
}
.auto-sync-history-facts strong,
.auto-sync-history-payload strong {
margin-top: 4px;
color: rgba(255, 255, 255, 0.78);
font-size: 11px;
line-height: 1.35;
overflow-wrap: anywhere;
}
.auto-sync-history-logs {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 190px;
overflow: auto;
margin-top: 10px;
padding: 10px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.18);
}
.auto-sync-history-logs div {
color: rgba(255, 255, 255, 0.54);
font-family: ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', monospace;
font-size: 10px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.auto-sync-history-logs .success { color: #4ade80; }
.auto-sync-history-logs .error { color: #ef4444; }
.auto-sync-history-logs .warning { color: #facc15; }
.auto-sync-history-empty {
margin: 24px;
padding: 44px;
@ -12358,6 +12485,15 @@ body.helper-mode-active #dashboard-activity-feed:hover {
grid-template-columns: repeat(10, minmax(230px, 250px));
padding: 18px;
}
.auto-sync-history-detail-grid,
.auto-sync-history-snapshots {
grid-template-columns: 1fr;
}
.auto-sync-history-facts.compact {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-height: 760px) {
@ -12428,12 +12564,19 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.auto-sync-history-meta {
justify-content: flex-start;
max-width: none;
}
.auto-sync-history-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.auto-sync-history-facts,
.auto-sync-history-facts.compact,
.auto-sync-history-payload {
grid-template-columns: 1fr;
}
.auto-sync-scheduled-card {
grid-template-columns: 1fr;
}