Auto-Sync: bulk schedule per source + custom interval columns

Two upgrades to the schedule board:

Bulk schedule. Each source group in the sidebar gets a small "Bulk"
button next to the title. Clicking it opens a popover with the same
ten standard buckets plus "Custom interval…" (prompts for hours) and
"Unschedule all". Picking a bucket POSTs/PUTs the schedule for every
schedulable playlist in that source. Result toast aggregates ok/fail
counts. Big quality-of-life for "I want every Spotify playlist
weekly" without 30 individual drags.

Custom interval columns. The board's column set is no longer the
hardcoded `AUTO_SYNC_BUCKETS` list — it's the union of those plus any
hour values currently in use by playlist_schedules. A 6h or 36h
schedule (created via the bulk custom prompt, or hand-edited in the
Automations page) now renders as its own dashed-border column instead
of silently disappearing from the board because it didn't match a
standard bucket. Standard columns still render solid; custom ones get
a "custom" eyebrow + dashed border so they're visually distinct.
This commit is contained in:
Broque Thomas 2026-05-25 14:54:30 -07:00
parent d668bf4e6d
commit d18f45dfec
3 changed files with 269 additions and 5 deletions

View file

@ -279,7 +279,14 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) {
const sidebarHtml = sourceKeys.length ? sourceKeys.map(source => `
<div class="auto-sync-source-group">
<div class="auto-sync-source-title">${_esc(autoSyncSourceLabel(source))}</div>
<div class="auto-sync-source-group-head">
<span class="auto-sync-source-title">${_esc(autoSyncSourceLabel(source))}</span>
<button type="button" class="auto-sync-source-bulk-btn"
onclick="event.stopPropagation(); openAutoSyncBulkMenu(event, '${_escAttr(source)}')"
title="Schedule all ${_escAttr(autoSyncSourceLabel(source))} playlists at the same interval">
Bulk
</button>
</div>
${grouped[source].map(p => {
const schedule = playlistSchedules[p.id];
const assigned = schedule ? autoSyncIntervalLabel(schedule.hours) : 'Unscheduled';
@ -305,12 +312,21 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) {
</div>
` : '';
const bucketHtml = AUTO_SYNC_BUCKETS.map(hours => {
// 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);
const bucketHtml = allBuckets.map(hours => {
const assigned = schedulablePlaylists.filter(p => playlistSchedules[p.id]?.hours === hours);
const isCustom = !AUTO_SYNC_BUCKETS.includes(hours);
return `
<div class="auto-sync-column" data-hours="${hours}" ondragover="autoSyncDragOver(event)" ondragleave="autoSyncDragLeave(event)" ondrop="autoSyncDrop(event, ${hours})">
<div class="auto-sync-column ${isCustom ? 'custom' : ''}" data-hours="${hours}" ondragover="autoSyncDragOver(event)" ondragleave="autoSyncDragLeave(event)" ondrop="autoSyncDrop(event, ${hours})">
<div class="auto-sync-column-head">
<span>${autoSyncBucketLabel(hours)}</span>
<span>${autoSyncBucketLabel(hours)}${isCustom ? ' <em>custom</em>' : ''}</span>
<small>${assigned.length} playlist${assigned.length === 1 ? '' : 's'}</small>
</div>
<div class="auto-sync-column-list">
@ -511,6 +527,145 @@ function loadMoreAutoSyncHistory() {
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 => `
<button type="button" onclick="bulkScheduleAutoSyncSource('${_escAttr(source)}', ${h})">
${_esc(autoSyncIntervalLabel(h))}
</button>
`).join('');
menu.innerHTML = `
<div class="auto-sync-bulk-menu-title">Schedule all ${_esc(autoSyncSourceLabel(source))}</div>
<div class="auto-sync-bulk-menu-buckets">${buttons}</div>
<button type="button" class="auto-sync-bulk-menu-custom"
onclick="promptAutoSyncBulkCustom('${_escAttr(source)}')">
Custom interval
</button>
<button type="button" class="auto-sync-bulk-menu-unschedule"
onclick="bulkUnscheduleAutoSyncSource('${_escAttr(source)}')">
Unschedule all
</button>
`;
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;

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: bulk schedule by source + custom interval columns', desc: 'each source group in the sidebar gets a Bulk button — opens a popover that lets you schedule every playlist in that group at a chosen interval in one click (1h / 2h / 4h / 8h / 12h / 16h / 24h / 48h / 72h / weekly) or "Custom interval…" for an arbitrary number of hours. also includes "Unschedule all" to clear a source\'s schedules. on the board itself, a schedule with a non-standard interval (e.g. 6h or 36h, created via Automations page or the custom prompt) now renders as its own dashed-border column instead of disappearing because it didn\'t match a hardcoded bucket.' },
{ 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.' },

View file

@ -11682,8 +11682,15 @@ body.helper-mode-active #dashboard-activity-feed:hover {
margin-bottom: 14px;
}
.auto-sync-source-title {
.auto-sync-source-group-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 6px 4px 4px;
}
.auto-sync-source-title {
color: rgba(255, 255, 255, 0.38);
font-size: 10px;
font-weight: 700;
@ -11691,6 +11698,107 @@ body.helper-mode-active #dashboard-activity-feed:hover {
letter-spacing: 0.3px;
}
.auto-sync-source-bulk-btn {
height: 20px;
padding: 0 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
background: transparent;
color: rgba(255, 255, 255, 0.45);
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.3px;
cursor: pointer;
font-family: inherit;
transition: all 0.2s ease;
}
.auto-sync-source-bulk-btn:hover {
background: rgba(var(--accent-rgb), 0.16);
border-color: rgba(var(--accent-rgb), 0.35);
color: rgb(var(--accent-light-rgb));
}
/* Bulk-schedule popover for a source group */
.auto-sync-bulk-menu {
position: fixed;
z-index: 10001;
min-width: 220px;
padding: 8px;
background: linear-gradient(135deg, #1f1f1f 0%, #161616 100%);
border: 1px solid rgba(var(--accent-rgb), 0.25);
border-radius: 10px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(var(--accent-rgb), 0.08);
display: flex;
flex-direction: column;
gap: 6px;
}
.auto-sync-bulk-menu-title {
padding: 6px 8px 4px;
color: rgba(255, 255, 255, 0.55);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.auto-sync-bulk-menu-buckets {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
}
.auto-sync-bulk-menu button {
padding: 6px 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
background: rgba(255, 255, 255, 0.03);
color: rgba(255, 255, 255, 0.75);
cursor: pointer;
font-size: 11px;
font-weight: 600;
text-align: left;
font-family: inherit;
transition: all 0.15s ease;
}
.auto-sync-bulk-menu button:hover {
background: rgba(var(--accent-rgb), 0.16);
border-color: rgba(var(--accent-rgb), 0.35);
color: rgb(var(--accent-light-rgb));
}
.auto-sync-bulk-menu-custom {
margin-top: 2px;
}
.auto-sync-bulk-menu-unschedule {
color: rgba(255, 255, 255, 0.55) !important;
}
.auto-sync-bulk-menu-unschedule:hover {
background: rgba(239, 68, 68, 0.18) !important;
border-color: rgba(239, 68, 68, 0.4) !important;
color: #ef4444 !important;
}
/* Custom-interval column tag */
.auto-sync-column.custom {
border-style: dashed;
}
.auto-sync-column-head em {
font-style: normal;
color: rgba(var(--accent-rgb), 0.7);
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.3px;
margin-left: 4px;
}
.auto-sync-playlist,
.auto-sync-scheduled-card {
border: 1px solid rgba(255, 255, 255, 0.07);