Auto-Sync Manager: redesign hourly + weekly boards as horizontal lanes

Replace the side-scrolling column board with vertically-stacked interval lanes (hourly) and day lanes Mon-Sun (weekly). Empty intervals/days collapse to thin dashed strips, busy ones grow; scheduled playlists flow as cards within a lane. Kills the horizontal scroll + the wasted whitespace of the old kanban columns, and the two boards now share one cohesive design.

Polish: accent gradient wash + gradient interval numerals + count badge on filled lanes, drag-over glow/lift, card pop-in animation, hover states. Also preserves the board's scroll position across the full re-render so dropping/removing a playlist no longer snaps it back to the top. Same drag-and-drop handlers + scheduled-card content reused; old column CSS is now unused (harmless).
This commit is contained in:
BoulderBadgeDad 2026-06-25 13:06:07 -07:00
parent 88ff47e115
commit 7a8b66fd2e
2 changed files with 211 additions and 15 deletions

View file

@ -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 `
<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)}${isCustom ? ' <em>custom</em>' : ''}</span>
<small>${assigned.length} playlist${assigned.length === 1 ? '' : 's'}</small>
<div class="auto-sync-lane ${filled ? 'filled' : 'empty'} ${isCustom ? 'custom' : ''}" data-hours="${hours}" ondragover="autoSyncDragOver(event)" ondragleave="autoSyncDragLeave(event)" ondrop="autoSyncDrop(event, ${hours})">
<div class="auto-sync-lane-badge">
<b>${autoSyncBucketLabel(hours)}</b>
<span>${_esc(autoSyncLaneCadence(hours))}${isCustom ? ' · custom' : ''}</span>
${filled ? `<em class="auto-sync-lane-count">${assigned.length}</em>` : ''}
</div>
<div class="auto-sync-column-list">
${assigned.length ? assigned.map(p => autoSyncScheduledCardHtml(p, playlistSchedules[p.id])).join('') : '<div class="auto-sync-drop-hint"><strong>Drop here</strong><span>Schedule playlists at this interval</span></div>'}
<div class="auto-sync-lane-track">
${filled
? assigned.map(p => autoSyncScheduledCardHtml(p, playlistSchedules[p.id])).join('')
: `<div class="auto-sync-lane-hint"><span class="auto-sync-lane-hint-ic">+</span> Drag a playlist here to sync ${_esc(autoSyncIntervalLabel(hours).toLowerCase())}</div>`}
</div>
</div>
`;
@ -514,7 +542,7 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) {
</div>
<div class="auto-sync-source-list">${sidebarHtml}${unavailableHtml}</div>
</aside>
<main class="auto-sync-board">${bucketHtml}</main>
<main class="auto-sync-lanes">${bucketHtml}</main>
</div>
`;
}
@ -598,21 +626,24 @@ function renderAutoSyncWeeklyPanel(playlists, playlistSchedules) {
});
});
// Day LANES (horizontal rows, MonSun) — 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('')
: '<div class="auto-sync-drop-hint"><strong>Drop here</strong><span>Schedule playlists on this day</span></div>';
: `<div class="auto-sync-lane-hint"><span class="auto-sync-lane-hint-ic">+</span> Drag a playlist here to sync every ${_esc(AUTO_SYNC_WEEKDAY_LABELS[day])}</div>`;
return `
<div class="auto-sync-column auto-sync-weekly-column" data-day="${day}"
<div class="auto-sync-lane ${filled ? 'filled' : 'empty'}" data-day="${day}"
ondragover="autoSyncWeeklyDragOver(event)"
ondragleave="autoSyncWeeklyDragLeave(event)"
ondrop="autoSyncWeeklyDrop(event, '${day}')">
<div class="auto-sync-column-head">
<span>${AUTO_SYNC_WEEKDAY_LABELS[day]}</span>
<small>${cards.length} playlist${cards.length === 1 ? '' : 's'}</small>
<div class="auto-sync-lane-badge">
<b>${_esc(AUTO_SYNC_WEEKDAY_LABELS[day])}</b>
<span>Weekly</span>
${filled ? `<em class="auto-sync-lane-count">${cards.length}</em>` : ''}
</div>
<div class="auto-sync-column-list">${cardHtml}</div>
<div class="auto-sync-lane-track">${cardHtml}</div>
</div>
`;
}).join('');
@ -637,7 +668,7 @@ function renderAutoSyncWeeklyPanel(playlists, playlistSchedules) {
</div>
<div class="auto-sync-source-list">${sidebarHtml}${unavailableHtml}</div>
</aside>
<main class="auto-sync-board auto-sync-weekly-board">${dayColumnsHtml}</main>
<main class="auto-sync-lanes auto-sync-weekly-lanes">${dayColumnsHtml}</main>
</div>
${editorHtml}
`;

View file

@ -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;