diff --git a/webui/static/auto-sync.js b/webui/static/auto-sync.js
index 8a91d3ad..74f08428 100644
--- a/webui/static/auto-sync.js
+++ b/webui/static/auto-sync.js
@@ -73,6 +73,17 @@ function autoSyncIntervalLabel(hours) {
return `Every ${hours} hour${hours === 1 ? '' : 's'}`;
}
+// Short cadence word for the lane badge (under the interval number).
+function autoSyncLaneCadence(hours) {
+ if (hours === 1) return 'Hourly';
+ if (hours === 12) return 'Twice a day';
+ if (hours === 24) return 'Daily';
+ if (hours === 168) return 'Weekly';
+ if (hours < 24) return `Every ${hours}h`;
+ const days = hours / 24;
+ return `Every ${days} days`;
+}
+
// Browser-detected default tz for new schedules. Used when the user
// creates a weekly schedule and hasn't picked an explicit tz — falls
// back to UTC on browsers where Intl is unavailable (very old ones).
@@ -354,6 +365,12 @@ function renderAutoSyncScheduleModal() {
const overlay = document.getElementById('auto-sync-schedule-modal');
if (!overlay) return;
+ // Preserve the visible lane board's scroll across the full re-render so dropping /
+ // removing a playlist doesn't snap it back to the top (the user used to have to scroll
+ // back). Targets the ACTIVE tab so it works for both the hourly + weekly boards.
+ const _prevLanes = overlay.querySelector('.auto-sync-tab-panel.active .auto-sync-lanes');
+ const _prevScroll = _prevLanes ? _prevLanes.scrollTop : null;
+
const { playlists, playlistSchedules, weeklySchedules, automationPipelines, runHistory, runHistoryTotal } = _autoSyncScheduleState;
const scheduledCount = Object.keys(playlistSchedules).length + Object.keys(weeklySchedules || {}).length;
const enabledCount = Object.values(playlistSchedules).filter(s => s.enabled).length
@@ -408,6 +425,11 @@ function renderAutoSyncScheduleModal() {
`;
populateAutoSyncHistoryList(overlay);
bindAutoSyncHistoryCardInteractions(overlay);
+
+ if (_prevScroll != null) {
+ const nl = overlay.querySelector('.auto-sync-tab-panel.active .auto-sync-lanes');
+ if (nl) nl.scrollTop = _prevScroll;
+ }
}
function setAutoSyncTab(tab) {
@@ -479,17 +501,23 @@ function renderAutoSyncSchedulePanel(playlists, 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 `
-
-
-
${autoSyncBucketLabel(hours)}${isCustom ? ' custom' : ''}
-
${assigned.length} playlist${assigned.length === 1 ? '' : 's'}
+
+
+ ${autoSyncBucketLabel(hours)}
+ ${_esc(autoSyncLaneCadence(hours))}${isCustom ? ' · custom' : ''}
+ ${filled ? `${assigned.length}` : ''}
-
- ${assigned.length ? assigned.map(p => autoSyncScheduledCardHtml(p, playlistSchedules[p.id])).join('') : '
Drop hereSchedule playlists at this interval
'}
+
+ ${filled
+ ? assigned.map(p => autoSyncScheduledCardHtml(p, playlistSchedules[p.id])).join('')
+ : `
+ Drag a playlist here to sync ${_esc(autoSyncIntervalLabel(hours).toLowerCase())}
`}
`;
@@ -514,7 +542,7 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) {
${sidebarHtml}${unavailableHtml}
-
${bucketHtml}
+
${bucketHtml}
`;
}
@@ -598,21 +626,24 @@ function renderAutoSyncWeeklyPanel(playlists, playlistSchedules) {
});
});
+ // Day LANES (horizontal rows, Mon–Sun) — mirrors the hourly board's lane layout.
const dayColumnsHtml = AUTO_SYNC_WEEKDAYS.map(day => {
const cards = cardsByDay[day];
- const cardHtml = cards.length
+ const filled = cards.length > 0;
+ const cardHtml = filled
? cards.map(({ playlist, schedule }) => autoSyncWeeklyCardHtml(playlist, schedule)).join('')
- : '
Drop hereSchedule playlists on this day
';
+ : `
+ Drag a playlist here to sync every ${_esc(AUTO_SYNC_WEEKDAY_LABELS[day])}
`;
return `
-
-
-
${AUTO_SYNC_WEEKDAY_LABELS[day]}
-
${cards.length} playlist${cards.length === 1 ? '' : 's'}
+
+ ${_esc(AUTO_SYNC_WEEKDAY_LABELS[day])}
+ Weekly
+ ${filled ? `${cards.length}` : ''}
-
${cardHtml}
+
${cardHtml}
`;
}).join('');
@@ -637,7 +668,7 @@ function renderAutoSyncWeeklyPanel(playlists, playlistSchedules) {
${sidebarHtml}${unavailableHtml}
-
${dayColumnsHtml}
+
${dayColumnsHtml}
${editorHtml}
`;
diff --git a/webui/static/style.css b/webui/static/style.css
index 4939df93..6b0f8677 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -12465,6 +12465,171 @@ body.helper-mode-active #dashboard-activity-feed:hover {
color: rgba(255, 255, 255, 0.45);
}
+/* ── Hourly board: interval LANES (horizontal rows — Concept 1) ───────────── */
+.auto-sync-lanes {
+ min-width: 0;
+ min-height: 0;
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding: 16px 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.auto-sync-lane {
+ display: flex;
+ align-items: stretch;
+ gap: 12px;
+ padding: 11px 14px;
+ border: 1px solid rgba(255, 255, 255, 0.07);
+ border-radius: 16px;
+ background: rgba(255, 255, 255, 0.025);
+ transition: border-color 0.22s ease, background 0.22s ease,
+ box-shadow 0.22s ease, transform 0.18s ease;
+}
+
+.auto-sync-lane.empty {
+ background: transparent;
+ border-style: dashed;
+ border-color: rgba(255, 255, 255, 0.08);
+}
+
+.auto-sync-lane.filled {
+ border-color: rgba(var(--accent-rgb), 0.22);
+ background:
+ linear-gradient(100deg, rgba(var(--accent-rgb), 0.07), rgba(var(--accent-rgb), 0.012) 55%),
+ rgba(255, 255, 255, 0.02);
+}
+
+.auto-sync-lane:hover {
+ border-color: rgba(var(--accent-rgb), 0.3);
+}
+
+.auto-sync-lane.drag-over {
+ border-color: rgba(var(--accent-rgb), 0.65);
+ border-style: solid;
+ background: rgba(var(--accent-rgb), 0.1);
+ box-shadow:
+ inset 0 0 0 1px rgba(var(--accent-rgb), 0.35),
+ 0 10px 30px rgba(var(--accent-rgb), 0.16);
+ transform: translateY(-1px);
+}
+
+.auto-sync-lane-badge {
+ position: relative;
+ flex: 0 0 86px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 2px;
+ padding: 4px 14px 4px 4px;
+ border-right: 1px solid rgba(255, 255, 255, 0.07);
+}
+
+.auto-sync-lane-badge b {
+ font-size: 21px;
+ font-weight: 800;
+ line-height: 1;
+ letter-spacing: -0.02em;
+ color: rgba(255, 255, 255, 0.92);
+}
+
+.auto-sync-lane.filled .auto-sync-lane-badge b {
+ background: linear-gradient(160deg, rgb(var(--accent-rgb)), rgba(var(--accent-rgb), 0.6));
+ -webkit-background-clip: text;
+ background-clip: text;
+ -webkit-text-fill-color: transparent;
+}
+
+.auto-sync-lane-badge span {
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: rgba(255, 255, 255, 0.4);
+ white-space: nowrap;
+}
+
+.auto-sync-lane-count {
+ position: absolute;
+ top: 0;
+ right: 10px;
+ min-width: 17px;
+ height: 17px;
+ padding: 0 4px;
+ display: grid;
+ place-items: center;
+ font-size: 10px;
+ font-weight: 800;
+ font-style: normal;
+ border-radius: 999px;
+ color: #fff;
+ background: rgba(var(--accent-rgb), 0.9);
+ box-shadow: 0 2px 8px rgba(var(--accent-rgb), 0.35);
+}
+
+.auto-sync-lane-track {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ padding: 2px 0;
+}
+
+.auto-sync-lane-hint {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ padding: 7px 2px;
+ color: rgba(255, 255, 255, 0.32);
+ font-size: 12.5px;
+ font-weight: 500;
+}
+
+.auto-sync-lane-hint-ic {
+ display: grid;
+ place-items: center;
+ width: 22px;
+ height: 22px;
+ border-radius: 7px;
+ border: 1px dashed rgba(255, 255, 255, 0.2);
+ font-size: 15px;
+ line-height: 1;
+}
+
+.auto-sync-lane.drag-over .auto-sync-lane-hint { color: rgb(var(--accent-rgb)); }
+.auto-sync-lane.drag-over .auto-sync-lane-hint-ic {
+ border-style: solid;
+ border-color: rgba(var(--accent-rgb), 0.6);
+ color: rgb(var(--accent-rgb));
+}
+
+/* Scheduled cards flow horizontally inside a lane (override the column full-width) */
+.auto-sync-lane .auto-sync-scheduled-card {
+ width: auto;
+ min-width: 212px;
+ max-width: 264px;
+ margin-bottom: 0;
+ padding: 10px 12px;
+ border-radius: 12px;
+ background: rgba(0, 0, 0, 0.28);
+ border: 1px solid rgba(255, 255, 255, 0.07);
+ animation: autoSyncPop 0.24s ease both;
+}
+
+.auto-sync-lane .auto-sync-scheduled-card:hover {
+ border-color: rgba(var(--accent-rgb), 0.4);
+ background: rgba(0, 0, 0, 0.36);
+}
+
+@keyframes autoSyncPop {
+ from { opacity: 0; transform: translateY(5px) scale(0.97); }
+ to { opacity: 1; transform: none; }
+}
+
.auto-sync-board {
min-width: 0;
min-height: 0;