Auto-Sync manager: filter, failure indicators, history filters

Six small UX additions on the Playlist Auto-Sync manager:

- Sidebar gets a "Filter playlists…" search input. Re-renders only
  the schedule panel on input so focus is preserved while typing.
- Scheduled cards show a red `!` badge + red border tint when the
  last three pipeline runs failed (yellow `⚠` if at least one of the
  last few failed). Surfaces chronically broken schedules visually
  instead of leaving them indistinguishable from healthy ones.
- Run History tab title shows a red error count badge when there are
  failed runs in the loaded window.
- Run History tab body gains All / Errors / Completed filter pills
  with per-bucket counts.
- Load-more button at the bottom of the history tab pulls another
  50 entries (capped at 500).
- "Run pipeline again" button in the expanded detail of each history
  card re-triggers that playlist's pipeline directly.

Also dropped the "Discovered: completed" result pill — `tracks_discovered`
in the result payload is a status string, not a count, and the same
data is already in the before/after stats grid above.
This commit is contained in:
Broque Thomas 2026-05-25 14:22:56 -07:00
parent dfdc6c6277
commit d668bf4e6d
3 changed files with 366 additions and 10 deletions

View file

@ -21,6 +21,9 @@ let _autoSyncScheduleState = {
runHistoryTotal: 0,
};
let _autoSyncActiveTab = 'schedule';
let _autoSyncSidebarFilter = '';
let _autoSyncHistoryFilter = 'all'; // 'all' | 'error' | 'completed' | 'skipped'
let _autoSyncHistoryLimit = 50;
function getMirroredSourceRef(p) {
if (p && p.source_ref) return String(p.source_ref);
@ -176,7 +179,7 @@ async function refreshAutoSyncScheduleModal() {
const [playlistRes, automationRes, historyRes] = await Promise.all([
fetch('/api/mirrored-playlists'),
fetch('/api/automations'),
fetch('/api/playlist-pipeline/history?limit=50'),
fetch(`/api/playlist-pipeline/history?limit=${_autoSyncHistoryLimit}`),
]);
const playlists = await playlistRes.json();
const automations = await automationRes.json();
@ -238,7 +241,13 @@ function renderAutoSyncScheduleModal() {
<div class="auto-sync-tabs">
<button class="${scheduleActive ? 'active' : ''}" onclick="setAutoSyncTab('schedule')">Schedule Board</button>
<button class="${automationsActive ? 'active' : ''}" onclick="setAutoSyncTab('automations')">Automation Pipelines</button>
<button class="${historyActive ? 'active' : ''}" onclick="setAutoSyncTab('history')">Run History</button>
<button class="${historyActive ? 'active' : ''}" onclick="setAutoSyncTab('history')">
Run History
${(() => {
const errs = (runHistory || []).filter(h => h.status === 'error' || h.status === 'skipped').length;
return errs ? `<span class="auto-sync-tab-badge error">${errs}</span>` : '';
})()}
</button>
</div>
<div class="auto-sync-tab-panel ${scheduleActive ? 'active' : ''}" id="auto-sync-schedule-panel">${schedulePanel}</div>
<div class="auto-sync-tab-panel ${automationsActive ? 'active' : ''}" id="auto-sync-automation-panel">${automationPanel}</div>
@ -255,8 +264,11 @@ function setAutoSyncTab(tab) {
}
function renderAutoSyncSchedulePanel(playlists, playlistSchedules) {
const schedulablePlaylists = playlists.filter(autoSyncCanSchedulePlaylist);
const unavailablePlaylists = playlists.filter(p => !autoSyncCanSchedulePlaylist(p));
const filter = (_autoSyncSidebarFilter || '').trim().toLowerCase();
const matchesFilter = (p) => !filter || (p.name || '').toLowerCase().includes(filter)
|| autoSyncSourceLabel(p.source || '').toLowerCase().includes(filter);
const schedulablePlaylists = playlists.filter(p => autoSyncCanSchedulePlaylist(p) && matchesFilter(p));
const unavailablePlaylists = playlists.filter(p => !autoSyncCanSchedulePlaylist(p) && matchesFilter(p));
const grouped = schedulablePlaylists.reduce((acc, p) => {
const key = p.source || 'other';
if (!acc[key]) acc[key] = [];
@ -308,6 +320,7 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) {
`;
}).join('');
const filterValue = _esc(_autoSyncSidebarFilter || '');
return `
<div class="auto-sync-board-intro">
<div>
@ -319,6 +332,11 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) {
<div class="auto-sync-body">
<aside class="auto-sync-sidebar">
<div class="auto-sync-sidebar-title">Mirrored playlists</div>
<div class="auto-sync-sidebar-filter">
<input type="search" class="auto-sync-sidebar-search" placeholder="Filter playlists…"
value="${filterValue}" oninput="setAutoSyncSidebarFilter(this.value)" />
${_autoSyncSidebarFilter ? `<button type="button" class="auto-sync-sidebar-filter-clear" onclick="setAutoSyncSidebarFilter('')" aria-label="Clear filter">&times;</button>` : ''}
</div>
<div class="auto-sync-source-list">${sidebarHtml}${unavailableHtml}</div>
</aside>
<main class="auto-sync-board">${bucketHtml}</main>
@ -326,6 +344,21 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) {
`;
}
function setAutoSyncSidebarFilter(value) {
_autoSyncSidebarFilter = String(value || '');
// Only re-render the sidebar/board portion to keep input focus.
const panel = document.getElementById('auto-sync-schedule-panel');
if (!panel) return;
const { playlists, playlistSchedules } = _autoSyncScheduleState;
panel.innerHTML = renderAutoSyncSchedulePanel(playlists, playlistSchedules);
// Restore focus to the search input + caret position at end.
const input = panel.querySelector('.auto-sync-sidebar-search');
if (input) {
input.focus();
try { input.setSelectionRange(value.length, value.length); } catch (_) {}
}
}
function getAutoSyncPipelinePlaylists(playlists) {
return playlists
.map(p => ({ playlist: p, state: p.pipeline_state || null }))
@ -431,28 +464,77 @@ function renderAutoSyncHistoryPanel(history, total) {
</div>
`;
}
const filter = _autoSyncHistoryFilter || 'all';
const tabs = [
['all', 'All', history.length],
['error', 'Errors', history.filter(h => h.status === 'error' || h.status === 'skipped').length],
['completed', 'Completed', history.filter(h => h.status === 'completed' || h.status === 'finished').length],
];
const filterTabsHtml = tabs.map(([key, label, count]) => `
<button class="auto-sync-history-filter-btn ${filter === key ? 'active' : ''} ${key === 'error' && count > 0 ? 'has-errors' : ''}"
onclick="setAutoSyncHistoryFilter('${key}')" type="button">
${_esc(label)} <em>${count}</em>
</button>
`).join('');
const canLoadMore = total > history.length;
return `
<div class="auto-sync-history-intro">
<div>
<strong>Playlist pipeline run history</strong>
<span>Each run records what changed on the mirrored playlist before and after refresh, discovery, sync, and wishlist processing.</span>
</div>
<button onclick="refreshAutoSyncScheduleModal()">Refresh</button>
<div class="auto-sync-history-intro-controls">
<div class="auto-sync-history-filters">${filterTabsHtml}</div>
<button onclick="refreshAutoSyncScheduleModal()">Refresh</button>
</div>
</div>
<div class="auto-sync-history-list" data-renderer="pending">
<div class="auto-sync-history-loading">Preparing run history...</div>
</div>
${canLoadMore ? `
<div class="auto-sync-history-load-more-row">
<button type="button" class="auto-sync-history-load-more" onclick="loadMoreAutoSyncHistory()">
Load more (${history.length} of ${total})
</button>
</div>
` : ''}
`;
}
function setAutoSyncHistoryFilter(key) {
_autoSyncHistoryFilter = ['error', 'completed'].includes(key) ? key : 'all';
renderAutoSyncScheduleModal();
}
function loadMoreAutoSyncHistory() {
_autoSyncHistoryLimit = Math.min(500, _autoSyncHistoryLimit + 50);
refreshAutoSyncScheduleModal();
}
function populateAutoSyncHistoryList(root = document) {
const list = root.querySelector('.auto-sync-history-list');
if (!list) return;
const history = Array.isArray(_autoSyncScheduleState.runHistory) ? _autoSyncScheduleState.runHistory : [];
const total = _autoSyncScheduleState.runHistoryTotal || 0;
const allHistory = Array.isArray(_autoSyncScheduleState.runHistory) ? _autoSyncScheduleState.runHistory : [];
const filter = _autoSyncHistoryFilter || 'all';
const history = allHistory.filter(h => {
if (filter === 'error') return h.status === 'error' || h.status === 'skipped';
if (filter === 'completed') return h.status === 'completed' || h.status === 'finished';
return true;
});
list.innerHTML = '';
list.dataset.renderer = 'dom-cards';
list.dataset.renderedCount = '0';
if (filter !== 'all' && !history.length) {
const note = document.createElement('div');
note.className = 'auto-sync-history-empty';
const strong = document.createElement('strong');
strong.textContent = filter === 'error' ? 'No failed runs in the loaded window' : 'No completed runs in the loaded window';
const small = document.createElement('span');
small.textContent = 'Switch filters or load more history.';
note.append(strong, small);
list.appendChild(note);
return;
}
history.forEach((entry, index) => {
try {
list.appendChild(createAutoSyncHistoryEntryElement(entry, index));
@ -769,9 +851,12 @@ function autoSyncHistoryDetailHtml(entry, before, after, result, deltas) {
['Duration', entry.duration_seconds ? autoSyncDurationLabel(entry.duration_seconds) : '—'],
['Source', entry.source || after.source || before.source || '—'],
].map(([k, v]) => `<div class="auto-sync-history-fact"><span>${_esc(k)}</span><strong>${_esc(autoSyncValueLabel(v))}</strong></div>`).join('');
// `tracks_discovered` was a status STRING (e.g. "completed"), not a
// count — kept it out of the pills so the panel doesn't show a
// confusing "Discovered: completed" chip. Same data is already
// surfaced as a before/after stat card above.
const resultPills = [
['Refreshed', result.playlists_refreshed],
['Discovered', result.tracks_discovered],
['Synced', result.tracks_synced],
['Skipped', result.sync_skipped],
['Wishlisted', result.wishlist_queued],
@ -779,10 +864,21 @@ function autoSyncHistoryDetailHtml(entry, before, after, result, deltas) {
.map(([k, v]) => `<span class="auto-sync-history-result-pill"><em>${_esc(k)}</em>${_esc(String(v))}</span>`).join('');
const errorBlock = result.error ? `<div class="auto-sync-history-error">${_esc(result.error)}</div>` : '';
const logsHtml = autoSyncHistoryLogsCompactHtml(entry.log_lines);
const playlistId = entry.playlist_id || after.playlist_id || before.playlist_id || '';
const playlistName = entry.playlist_name || after.name || before.name || '';
const runAgainHtml = playlistId
? `<div class="auto-sync-history-detail-actions">
<button type="button" class="auto-sync-history-run-again"
onclick="event.stopPropagation(); runMirroredPlaylistPipeline(${parseInt(playlistId, 10)}, '${_escAttr(playlistName)}')">
Run pipeline again
</button>
</div>`
: '';
return `
<div class="auto-sync-history-stats-grid">${statsHtml}</div>
<div class="auto-sync-history-facts-row">${factsHtml}</div>
${resultPills ? `<div class="auto-sync-history-result-row">${resultPills}</div>` : ''}
${runAgainHtml}
${errorBlock}
${logsHtml}
`;
@ -944,10 +1040,17 @@ function autoSyncScheduledCardHtml(playlist, schedule) {
const enabled = schedule?.enabled !== false;
const nextLabel = schedule?.next_run ? autoSyncNextRunLabel(schedule.next_run) : '';
const isRunning = playlist.pipeline_state?.status === 'running';
const health = autoSyncPlaylistHealth(playlist.id);
const healthClass = health.level === 'failing' ? 'failing'
: health.level === 'warning' ? 'warning'
: '';
return `
<div class="auto-sync-scheduled-card ${enabled ? '' : 'disabled'}" draggable="true" data-playlist-id="${playlist.id}" ondragstart="autoSyncDragStart(event)" ondragend="autoSyncDragEnd()">
<div class="auto-sync-scheduled-card ${enabled ? '' : 'disabled'} ${healthClass}" draggable="true" data-playlist-id="${playlist.id}" ondragstart="autoSyncDragStart(event)" ondragend="autoSyncDragEnd()">
<div class="auto-sync-scheduled-main">
<div class="auto-sync-scheduled-name">${_esc(playlist.name)}</div>
<div class="auto-sync-scheduled-name">
${health.level !== 'ok' ? `<span class="auto-sync-scheduled-health ${healthClass}" title="${_escAttr(health.tooltip)}">${health.level === 'failing' ? '!' : '⚠'}</span>` : ''}
${_esc(playlist.name)}
</div>
<div class="auto-sync-scheduled-meta">${_esc(autoSyncSourceLabel(playlist.source))} &middot; ${playlist.track_count || 0} tracks</div>
<div class="auto-sync-scheduled-timing">
<span>${_esc(autoSyncIntervalLabel(schedule?.hours || 24))}</span>
@ -962,6 +1065,25 @@ function autoSyncScheduledCardHtml(playlist, schedule) {
`;
}
function autoSyncPlaylistHealth(playlistId) {
// Look at the last 3 runs for this playlist in the loaded history.
// 3-in-a-row errors = failing (red dot). 1+ recent error = warning.
const history = _autoSyncScheduleState.runHistory || [];
const id = parseInt(playlistId, 10);
const recent = history
.filter(h => parseInt(h.playlist_id, 10) === id)
.slice(0, 3);
if (!recent.length) return { level: 'ok', tooltip: '' };
const errored = recent.filter(h => h.status === 'error' || h.status === 'skipped');
if (errored.length >= 3) {
return { level: 'failing', tooltip: `Last ${recent.length} runs failed — check Run History tab` };
}
if (errored.length) {
return { level: 'warning', tooltip: `${errored.length} of last ${recent.length} runs failed` };
}
return { level: 'ok', tooltip: '' };
}
function autoSyncNextRunLabel(nextRun) {
if (!nextRun) return '';
const ts = _autoParseUTC(nextRun);

View file

@ -3415,6 +3415,7 @@ function closeHelperSearch() {
const WHATS_NEW = {
'2.6.2': [
{ date: 'May 24, 2026 — 2.6.2 release' },
{ title: 'Auto-Sync manager: filter, failure indicators, history filters + load more', desc: 'a handful of UX additions on the Playlist Auto-Sync modal. sidebar gets a "Filter playlists…" search input so you can narrow down a long mirrored-playlist list. scheduled cards on the board now show a red ! badge when the last three pipeline runs all failed (yellow ⚠ if at least one of the last few failed) so chronically broken schedules surface visually instead of getting buried in the run-history tab. run history tab title shows a red error count when there are failed runs in the loaded window. the history tab itself gains All / Errors / Completed filter pills, a "Load more" footer that pulls another 50 entries, and a "Run pipeline again" button inside the expanded detail panel so you can re-trigger a specific playlist without leaving the modal. also dropped the "Discovered: completed" pill — `tracks_discovered` was a status string, not a count, and the same data is already in the before/after stats grid above.' },
{ title: 'Auto-Sync manager redesigned to match the rest of the app', desc: 'the Auto-Sync modal got a top-to-bottom restyle so it stops feeling like it lives in a different app. modal shell now uses the standard SoulSync gradient + accent-tinted border + 20px radius, the KPI summary became inset stat tiles, the live pipeline monitor is auto-fill instead of a fixed 4-col grid, tabs are now underline-style instead of pill buttons, and the schedule board sidebar/columns use the dense dark-card pattern that Automations + Library pages already use. modal also fills more of the screen since there was a lot of unused real estate. run history cards got the same treatment — slim horizontal row matching `.automation-card` (status dot + name + flow chips + meta row), and the expanded panel uses the same stats-grid / log-section structure as the Automations run-history modal.' },
{ title: 'Fix: Auto-Sync manager hung forever on "Loading schedule..."', desc: 'a regression while wiring up the new "in library" count: the join used `COLLATE NOCASE` on `tracks.title` and `artists.name`, which can\'t use the indexes those columns have. on a 300k-track library each playlist took ~18s. with 30 mirrored playlists the modal\'s `/api/mirrored-playlists` call would never come back. switched to case-sensitive equality so SQLite uses `idx_artists_name` + `idx_tracks_title` (6ms per playlist). misses pure-case-different matches but Spotify names are canonical against library imports, so it\'s a rounding-error tradeoff for the 3000× speedup.' },
{ title: 'Fix: Auto-Sync run history `in library` always showed 0', desc: 'the SQL query was referencing `tracks.artist` — a column that hasn\'t existed since the schema went relational. `tracks` has `artist_id` (FK to `artists`), so the query threw "no such column", got swallowed by the surrounding try/except, and the count silently defaulted to 0 on every playlist. rewired the join through `artists` so the count actually reflects how many mirrored tracks already live in your library.' },

View file

@ -11622,6 +11622,56 @@ body.helper-mode-active #dashboard-activity-feed:hover {
letter-spacing: 0.3px;
}
.auto-sync-sidebar-filter {
position: relative;
padding: 4px 16px 10px;
}
.auto-sync-sidebar-search {
width: 100%;
height: 30px;
padding: 0 28px 0 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
color: #fff;
font-size: 12px;
font-family: inherit;
outline: none;
transition: border-color 0.2s ease, background 0.2s ease;
}
.auto-sync-sidebar-search:focus {
border-color: rgba(var(--accent-rgb), 0.45);
background: rgba(var(--accent-rgb), 0.04);
}
.auto-sync-sidebar-search::placeholder {
color: rgba(255, 255, 255, 0.3);
}
.auto-sync-sidebar-filter-clear {
position: absolute;
right: 22px;
top: 50%;
transform: translateY(-50%);
width: 18px;
height: 18px;
border: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.65);
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0;
}
.auto-sync-sidebar-filter-clear:hover {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.auto-sync-source-list {
min-height: 0;
overflow-y: auto;
@ -11963,6 +12013,189 @@ body.helper-mode-active #dashboard-activity-feed:hover {
border-color: rgba(239, 68, 68, 0.4);
}
.auto-sync-scheduled-card.failing {
border-color: rgba(239, 68, 68, 0.4);
box-shadow: 0 0 0 1px rgba(239, 68, 68, 0.15);
}
.auto-sync-scheduled-card.warning {
border-color: rgba(250, 204, 21, 0.3);
}
.auto-sync-scheduled-health {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
margin-right: 6px;
font-size: 10px;
font-weight: 800;
line-height: 1;
vertical-align: middle;
}
.auto-sync-scheduled-health.failing {
background: rgba(239, 68, 68, 0.18);
color: #ef4444;
box-shadow: 0 0 6px rgba(239, 68, 68, 0.35);
}
.auto-sync-scheduled-health.warning {
background: rgba(250, 204, 21, 0.18);
color: #facc15;
}
/* Run history filters + load more + tab badge */
.auto-sync-history-intro-controls {
display: flex;
align-items: center;
gap: 10px;
}
.auto-sync-history-filters {
display: flex;
gap: 4px;
padding: 3px;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
background: rgba(0, 0, 0, 0.25);
}
.auto-sync-history-filter-btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 26px;
padding: 0 10px;
border: 0;
border-radius: 7px;
background: transparent;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
font-size: 11px;
font-weight: 600;
font-family: inherit;
transition: color 0.15s ease, background 0.15s ease;
}
.auto-sync-history-filter-btn:hover {
color: rgba(255, 255, 255, 0.85);
}
.auto-sync-history-filter-btn.active {
background: rgba(var(--accent-rgb), 0.18);
color: rgb(var(--accent-light-rgb));
}
.auto-sync-history-filter-btn em {
font-style: normal;
padding: 1px 6px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.55);
font-size: 10px;
font-weight: 700;
}
.auto-sync-history-filter-btn.active em {
background: rgba(var(--accent-rgb), 0.25);
color: rgb(var(--accent-light-rgb));
}
.auto-sync-history-filter-btn.has-errors:not(.active) em {
background: rgba(239, 68, 68, 0.18);
color: #ef4444;
}
.auto-sync-history-filter-btn.has-errors.active {
background: rgba(239, 68, 68, 0.16);
color: #ef4444;
}
.auto-sync-history-load-more-row {
display: flex;
justify-content: center;
padding: 12px 14px 20px;
border-top: 1px solid rgba(255, 255, 255, 0.04);
}
.auto-sync-history-load-more {
height: 32px;
padding: 0 18px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.2px;
transition: all 0.2s ease;
font-family: inherit;
}
.auto-sync-history-load-more:hover {
background: rgba(var(--accent-rgb), 0.16);
border-color: rgba(var(--accent-rgb), 0.4);
color: rgb(var(--accent-light-rgb));
}
/* Tab badge (error count on Run History tab) */
.auto-sync-tab-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 16px;
padding: 0 5px;
margin-left: 6px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.7);
font-size: 10px;
font-weight: 700;
line-height: 1;
vertical-align: middle;
}
.auto-sync-tab-badge.error {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
/* Run-again button inside history expanded detail */
.auto-sync-history-detail-actions {
display: flex;
justify-content: flex-end;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.04);
}
.auto-sync-history-run-again {
height: 30px;
padding: 0 14px;
border: 1px solid rgba(var(--accent-rgb), 0.3);
border-radius: 8px;
background: rgba(var(--accent-rgb), 0.12);
color: rgb(var(--accent-light-rgb));
cursor: pointer;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.3px;
text-transform: uppercase;
font-family: inherit;
transition: all 0.2s ease;
}
.auto-sync-history-run-again:hover {
background: rgba(var(--accent-rgb), 0.22);
border-color: rgba(var(--accent-rgb), 0.5);
color: rgb(var(--accent-neon-rgb));
}
.auto-sync-automation-list {
min-height: 0;
overflow-y: auto;