${_esc(autoSyncSourceLabel(p.source))} · refresh not supported
`).join('')}
` : '';
// Merge standard buckets with any custom intervals that are already in
// use, so a 6h or 36h schedule (created via Automations page or the
// custom-interval prompt) still renders as its own column instead of
// disappearing from the board.
const customHours = Object.values(playlistSchedules)
.map(s => parseInt(s?.hours, 10))
.filter(h => Number.isFinite(h) && h > 0 && !AUTO_SYNC_BUCKETS.includes(h));
const allBuckets = [...new Set([...AUTO_SYNC_BUCKETS, ...customHours])].sort((a, b) => a - b);
// Concept 1 — interval LANES (horizontal rows) instead of columns: no side-scroll,
// empty intervals collapse to thin strips, busy ones grow. Same drag handlers + card.
const bucketHtml = allBuckets.map(hours => {
const assigned = schedulablePlaylists.filter(p => playlistSchedules[p.id]?.hours === hours);
const isCustom = !AUTO_SYNC_BUCKETS.includes(hours);
const filled = assigned.length > 0;
return `
Playlist pipeline run historyEach run records what changed on the mirrored playlist before and after refresh, discovery, sync, and wishlist processing.
${filterTabsHtml}
Preparing run history...
${canLoadMore ? `
` : ''}
`;
}
function setAutoSyncHistoryFilter(key) {
_autoSyncHistoryFilter = ['error', 'completed'].includes(key) ? key : 'all';
renderAutoSyncScheduleModal();
}
function loadMoreAutoSyncHistory() {
_autoSyncHistoryLimit = Math.min(500, _autoSyncHistoryLimit + 50);
refreshAutoSyncScheduleModal();
}
function openAutoSyncBulkMenu(event, source) {
// Build a transient popover with all the standard buckets + a "Custom…"
// entry. Position relative to the button that triggered it.
closeAutoSyncBulkMenu();
const anchor = event.currentTarget;
if (!anchor) return;
const menu = document.createElement('div');
menu.className = 'auto-sync-bulk-menu';
menu.id = 'auto-sync-bulk-menu';
const buckets = [...AUTO_SYNC_BUCKETS];
const buttons = buckets.map(h => `
`).join('');
menu.innerHTML = `
Schedule all ${_esc(autoSyncSourceLabel(source))}
${buttons}
`;
document.body.appendChild(menu);
const rect = anchor.getBoundingClientRect();
menu.style.top = `${rect.bottom + 4}px`;
menu.style.left = `${Math.max(8, rect.right - menu.offsetWidth)}px`;
// Close on outside click
setTimeout(() => {
document.addEventListener('click', _autoSyncBulkMenuOutsideClick, { once: true });
}, 0);
}
function _autoSyncBulkMenuOutsideClick(event) {
const menu = document.getElementById('auto-sync-bulk-menu');
if (menu && !menu.contains(event.target)) closeAutoSyncBulkMenu();
}
function closeAutoSyncBulkMenu() {
const existing = document.getElementById('auto-sync-bulk-menu');
if (existing) existing.remove();
}
function promptAutoSyncBulkCustom(source) {
closeAutoSyncBulkMenu();
const raw = window.prompt('Custom interval in hours (e.g. 6, 36, 96):', '6');
if (raw === null) return;
const hours = parseInt(raw, 10);
if (!Number.isFinite(hours) || hours < 1) {
showToast('Interval must be a whole number of hours, 1 or greater', 'error');
return;
}
bulkScheduleAutoSyncSource(source, hours);
}
async function bulkScheduleAutoSyncSource(source, hours) {
closeAutoSyncBulkMenu();
const { playlists } = _autoSyncScheduleState;
const targets = (playlists || []).filter(p => p.source === source && autoSyncCanSchedulePlaylist(p));
if (!targets.length) {
showToast(`No schedulable ${autoSyncSourceLabel(source)} playlists`, 'info');
return;
}
if (!await showConfirmDialog({
title: `Schedule ${targets.length} ${autoSyncSourceLabel(source)} playlist${targets.length === 1 ? '' : 's'}`,
message: `Every ${autoSyncIntervalLabel(hours).toLowerCase().replace(/^every /, '')}. Existing schedules in this source will be updated.`,
})) return;
let ok = 0, fail = 0;
for (const playlist of targets) {
try {
await saveAutoSyncPlaylistScheduleSilent(playlist.id, hours);
ok++;
} catch (_err) {
fail++;
}
}
showToast(`Scheduled ${ok} ${autoSyncSourceLabel(source)} playlist${ok === 1 ? '' : 's'} at ${autoSyncBucketLabel(hours)}${fail ? ` (${fail} failed)` : ''}`, fail ? 'warning' : 'success');
await refreshAutoSyncScheduleModal();
}
async function bulkUnscheduleAutoSyncSource(source) {
closeAutoSyncBulkMenu();
const { playlists, playlistSchedules } = _autoSyncScheduleState;
const targets = (playlists || []).filter(p => p.source === source && playlistSchedules[p.id]);
if (!targets.length) {
showToast(`No scheduled ${autoSyncSourceLabel(source)} playlists to unschedule`, 'info');
return;
}
if (!await showConfirmDialog({
title: `Unschedule ${targets.length} ${autoSyncSourceLabel(source)} playlist${targets.length === 1 ? '' : 's'}`,
message: 'Removes the Auto-Sync schedules. Mirrored playlists themselves stay.',
})) return;
let ok = 0, fail = 0;
for (const playlist of targets) {
const schedule = playlistSchedules[playlist.id];
if (!schedule) continue;
try {
const res = await fetch(`/api/automations/${schedule.automation_id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
ok++;
} catch (_err) {
fail++;
}
}
showToast(`Removed ${ok} schedule${ok === 1 ? '' : 's'}${fail ? ` (${fail} failed)` : ''}`, fail ? 'warning' : 'success');
await refreshAutoSyncScheduleModal();
}
async function saveAutoSyncPlaylistScheduleSilent(playlistId, hours) {
// Like saveAutoSyncPlaylistSchedule but without toasts/refresh — caller
// batches feedback. Re-uses the existing automation row when one already
// exists for the playlist.
const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10));
if (!playlist) throw new Error('playlist not found');
const existing = _autoSyncScheduleState.playlistSchedules[playlistId];
const payload = {
name: `Auto-Sync: ${playlist.name}`,
trigger_type: 'schedule',
trigger_config: autoSyncTriggerForHours(hours),
action_type: 'playlist_pipeline',
action_config: { playlist_id: String(playlistId), all: false },
then_actions: [],
group_name: 'Playlist Auto-Sync',
owned_by: 'auto_sync',
};
const res = await fetch(existing ? `/api/automations/${existing.automation_id}` : '/api/automations', {
method: existing ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || 'Failed');
return data;
}
function populateAutoSyncHistoryList(root = document) {
const list = root.querySelector('.auto-sync-history-list');
if (!list) return;
const allHistory = Array.isArray(_autoSyncScheduleState.runHistory) ? _autoSyncScheduleState.runHistory : [];
const total = _autoSyncScheduleState.runHistoryTotal || 0;
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));
} catch (err) {
list.appendChild(createAutoSyncHistoryErrorElement(entry, index, err));
}
});
const renderedCount = list.querySelectorAll('.auto-sync-history-entry').length;
list.dataset.renderedCount = String(renderedCount);
if (history.length && !renderedCount) {
list.appendChild(createAutoSyncHistoryListFallback(history.length));
}
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 createAutoSyncHistoryListFallback(count) {
const fallback = document.createElement('div');
fallback.className = 'auto-sync-history-empty';
const title = document.createElement('strong');
title.textContent = 'Run history could not render';
const detail = document.createElement('span');
detail.textContent = `${count} playlist pipeline run${count === 1 ? '' : 's'} loaded, but the card renderer did not complete. Refresh the page to reload the latest Auto-Sync assets.`;
fallback.append(title, detail);
return fallback;
}
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);
const status = entry.status || 'completed';
const before = entry.before_json || {};
const after = entry.after_json || {};
const result = entry.result_json || {};
const started = entry.started_at ? _autoTimeAgo(entry.started_at) : '';
const duration = entry.duration_seconds ? autoSyncDurationLabel(entry.duration_seconds) : '';
const trackDelta = autoSyncDelta(after.track_count, before.track_count);
const discoveredDelta = autoSyncDelta(after.discovered_count, before.discovered_count);
const wishlistDelta = autoSyncDelta(after.wishlisted_count, before.wishlisted_count);
const libraryDelta = autoSyncDelta(after.in_library_count, before.in_library_count);
const entryId = `auto-sync-history-${entry.id}`;
const playlistName = entry.playlist_name || after.name || before.name || `Playlist #${entry.playlist_id || 'unknown'}`;
const triggerSource = entry.trigger_source || 'pipeline';
const card = document.createElement('article');
card.className = `auto-sync-history-entry ${status === 'completed' || status === 'finished' ? '' : 'auto-sync-history-entry-' + status}`.trim();
card.id = `${entryId}-card`;
card.dataset.historyEntry = entryId;
const row = document.createElement('div');
row.className = 'auto-sync-history-row';
row.setAttribute('role', 'button');
row.tabIndex = 0;
row.setAttribute('aria-expanded', 'false');
row.setAttribute('aria-controls', entryId);
row.dataset.historyToggle = entryId;
const dot = document.createElement('span');
dot.className = `auto-sync-card-status-dot ${autoSyncHistoryStatusClass(status)}`;
const info = document.createElement('div');
info.className = 'auto-sync-history-info';
const name = document.createElement('div');
name.className = 'auto-sync-history-name';
name.textContent = playlistName;
const flow = document.createElement('div');
flow.className = 'auto-sync-history-flow';
autoSyncAppendFlowChip(flow, triggerSource, '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 metaRow = document.createElement('div');
metaRow.className = 'auto-sync-history-meta-inline';
const statusBadge = document.createElement('span');
statusBadge.className = `auto-sync-history-status ${status}`;
statusBadge.textContent = autoSyncHistoryStatusLabel(status);
metaRow.appendChild(statusBadge);
if (started) {
const t = document.createElement('span');
t.className = 'auto-sync-history-time';
t.textContent = started;
metaRow.appendChild(t);
}
if (duration) {
const d = document.createElement('span');
d.className = 'auto-sync-history-duration';
d.textContent = duration;
metaRow.appendChild(d);
}
const trackChip = document.createElement('span');
const trackClass = trackDelta > 0 ? 'pos' : trackDelta < 0 ? 'neg' : 'zero';
trackChip.className = `auto-sync-history-delta ${trackClass}`;
trackChip.textContent = autoSyncDeltaLabel(after.track_count, trackDelta, 'tracks');
metaRow.appendChild(trackChip);
info.append(name, flow, metaRow);
const actions = document.createElement('div');
actions.className = 'auto-sync-history-actions';
const expand = document.createElement('button');
expand.type = 'button';
expand.className = 'auto-sync-history-expand-btn';
expand.dataset.historyToggleButton = entryId;
expand.setAttribute('aria-label', 'Toggle details');
expand.innerHTML = '▾';
actions.appendChild(expand);
row.append(dot, info, actions);
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 });
card.append(row, detail);
return card;
}
function autoSyncDeltaLabel(after, delta, unit) {
const a = parseInt(after, 10) || 0;
if (!delta) return `${a} ${unit}`;
const sign = delta > 0 ? '+' : '';
return `${a} ${unit} (${sign}${delta})`;
}
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) {
if (!entry || typeof entry !== 'object') {
return {
id: `unknown-${index}`,
status: 'completed',
playlist_name: 'Playlist pipeline run',
trigger_source: 'pipeline',
summary: 'Run history entry did not include detailed metadata.',
before_json: {},
after_json: {},
result_json: {},
};
}
return {
...entry,
id: entry.id ?? `history-${index}`,
before_json: autoSyncParseHistoryObject(entry.before_json),
after_json: autoSyncParseHistoryObject(entry.after_json),
result_json: autoSyncParseHistoryObject(entry.result_json),
};
}
function bindAutoSyncHistoryCardInteractions(root = document) {
root.querySelectorAll('[data-history-toggle]').forEach(row => {
const entryId = row.dataset.historyToggle;
row.addEventListener('click', () => autoSyncToggleHistoryEntry(entryId));
row.addEventListener('keydown', event => autoSyncHistoryEntryKeydown(event, entryId));
});
root.querySelectorAll('[data-history-toggle-button]').forEach(button => {
const entryId = button.dataset.historyToggleButton;
button.addEventListener('click', event => {
event.stopPropagation();
autoSyncToggleHistoryEntry(entryId);
});
});
}
function autoSyncParseHistoryObject(value) {
if (!value) return {};
if (typeof value === 'object') return value;
if (typeof value !== 'string') return {};
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch (_err) {
return {};
}
}
function autoSyncHistoryFallbackSummary(before, after, status) {
const beforeTracks = parseInt(before.track_count, 10) || 0;
const afterTracks = parseInt(after.track_count, 10) || 0;
return `${autoSyncHistoryStatusLabel(status)} | ${beforeTracks} -> ${afterTracks} tracks`;
}
function autoSyncToggleHistoryEntry(entryId) {
const el = document.getElementById(entryId);
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) {
if (status === 'completed' || status === 'finished') return 'Completed';
if (status === 'error') return 'Error';
if (status === 'skipped') return 'Skipped';
return status || 'Run';
}
function autoSyncHistoryStatusClass(status) {
if (status === 'completed' || status === 'finished') return 'enabled';
if (status === 'error' || status === 'skipped') return 'disabled';
return 'enabled';
}
function autoSyncDurationLabel(seconds) {
const total = Math.max(0, Math.round(parseFloat(seconds) || 0));
if (total < 60) return `${total}s`;
const mins = Math.floor(total / 60);
const secs = total % 60;
return `${mins}m ${secs}s`;
}
function autoSyncDelta(after, before) {
const a = parseInt(after, 10) || 0;
const b = parseInt(before, 10) || 0;
return a - b;
}
function autoSyncHistoryStatHtml(label, before, after, delta) {
const beforeValue = parseInt(before, 10) || 0;
const afterValue = parseInt(after, 10) || 0;
const deltaText = delta ? ` (${delta > 0 ? '+' : ''}${delta})` : '';
return `
`).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],
['Synced', result.tracks_synced],
['Skipped', result.sync_skipped],
['Wishlisted', result.wishlist_queued],
].filter(([, v]) => v !== undefined && v !== null && v !== '')
.map(([k, v]) => `${_esc(k)}${_esc(String(v))}`).join('');
const errorBlock = result.error ? `