Auto-Sync Weekly Board: weekday schedules in the UI (PR 3/4)

PR 3 of the schedule-types feature — see
``memory/project_auto_sync_schedule_types.md``. Backend
``next_run_at`` + ``weekly_time`` trigger handler landed in PRs 1-2.
This PR exposes them in the Auto-Sync manager so users can finally
schedule playlists by day-of-week + time instead of only hourly
intervals.

**UI layout:**

The Auto-Sync modal grows a ``Weekly Board`` tab between
``Hourly Board`` (renamed from ``Schedule Board``) and
``Automation Pipelines``. Same sidebar (mirrored playlists grouped
by source, with filter). Main panel is 7 day columns Mon-Sun
instead of 10 hour buckets. Drag a playlist onto a day column →
creates a single-day weekly schedule at the default time
(09:00 in the browser's IANA tz from
``Intl.DateTimeFormat().resolvedOptions().timeZone``). Click any
scheduled card → opens an editor popover for time, multi-day
toggles, tz override, and unschedule.

Multi-day schedules render under every matching column (Mon-Wed-Fri
schedule appears as three cards, one per column) — matches how
users think about "this playlist runs on Mon AND Wed AND Fri".

**Mutual exclusion:** one schedule per playlist. The save path on
either tab deletes any existing schedule of the OTHER kind before
installing the new one. Backend can technically run both as two
separate automation rows, but two cards under the same playlist
would surprise users and the engine has no merge semantic for
"daily-and-hourly".

**Pure-function helpers** (testable via node:test, matching the
existing ``tests/static/test_auto_sync.mjs`` pattern):

- ``detectBrowserTimezone()`` — Intl tz with UTC fallback for
  browsers where Intl is absent.
- ``autoSyncWeeklyTrigger({time, days, tz})`` — defensive payload
  builder: garbage time → 09:00, unrecognised days dropped,
  missing tz → browser tz.
- ``autoSyncWeeklyFromTrigger(config)`` — inverse parser with
  the same defensive shape. Empty days expands to every weekday
  (matches ``next_run_at`` engine semantic). Returns null for
  non-object configs so ``buildAutoSyncScheduleState`` can route
  broken rows to automationPipelines instead of silently
  bucketing them as every-day weekly.
- ``autoSyncWeeklyLabel(parsed)`` — sorted "Mon, Wed, Fri @
  09:00" / collapses to "Daily @ HH:MM" for full-week / "Unscheduled"
  for null. Canonical Mon-Sun ordering regardless of input order.

**Tests:** 26 new node:test cases across ``detectBrowserTimezone``
x1, ``autoSyncWeeklyTrigger`` x6, ``autoSyncWeeklyFromTrigger`` x6,
``autoSyncWeeklyLabel`` x5, and ``buildAutoSyncScheduleState``
weekly bucketing x5 (covering owned weekly_time → weeklySchedules,
hourly stays in playlistSchedules, non-owned falls through to
automationPipelines, legacy-named auto-sync rows still recognised,
garbage trigger_config falls through). All 62 node:test cases pass;
261 across the automation pytest suite still green (zero regression
on PRs 1-2's plumbing). Python wrapper at
``tests/test_auto_sync_js.py`` shells out cleanly.

**CSS** (themed to the existing Auto-Sync gradient + accent
variables):
- 7-column grid for the weekly board, narrower than the 10
  hour-bucket layout.
- Editor popover with backdrop-blur, accent-tinted save / delete
  buttons, hover states that pick up the user's accent color.
- ``scheduled-elsewhere`` state for playlists with an hourly
  schedule visible on the weekly board (dashed border + opacity)
  so the user knows a drop will replace, not stack.

**WHATS_NEW entry** under 2.6.3 unreleased — first user-visible
slice of the schedule-types feature.

PR 4 (Monthly UI tab) deferred until weekly proves wanted.
This commit is contained in:
Broque Thomas 2026-05-27 12:39:56 -07:00
parent f23761cf59
commit 698c21c3ce
4 changed files with 1048 additions and 18 deletions

View file

@ -392,3 +392,295 @@ describe('getMirroredSourceRef', () => {
assert.equal(sb.getMirroredSourceRef({}), '');
});
});
// =========================================================================
// Weekly schedule helpers — PR 3 of the schedule-types feature.
// =========================================================================
describe('detectBrowserTimezone', () => {
test('returns IANA tz from Intl in the test runtime', () => {
const sb = makeSandbox();
// Node runs with a system tz; the resolved value must be a
// non-empty string (any IANA name is acceptable here).
const tz = sb.detectBrowserTimezone();
assert.equal(typeof tz, 'string');
assert.ok(tz.length > 0);
});
});
describe('autoSyncWeeklyTrigger', () => {
test('builds a clean payload from picker input', () => {
const sb = makeSandbox();
const result = sb.autoSyncWeeklyTrigger({
time: '09:00',
days: ['mon', 'wed', 'fri'],
tz: 'America/Los_Angeles',
});
deepShapeEqual(result, {
time: '09:00',
days: ['mon', 'wed', 'fri'],
tz: 'America/Los_Angeles',
});
});
test('garbage time string defaults to 09:00', () => {
const sb = makeSandbox();
const result = sb.autoSyncWeeklyTrigger({
time: 'lol', days: ['mon'], tz: 'UTC',
});
assert.equal(result.time, '09:00');
});
test('unrecognised weekday abbreviations dropped from payload', () => {
const sb = makeSandbox();
const result = sb.autoSyncWeeklyTrigger({
time: '09:00',
days: ['mon', 'garbage', 'wed', 'mond'],
tz: 'UTC',
});
deepShapeEqual(result.days, ['mon', 'wed']);
});
test('missing tz falls back to browser-detected default', () => {
const sb = makeSandbox();
const result = sb.autoSyncWeeklyTrigger({
time: '09:00', days: ['mon'],
});
assert.equal(typeof result.tz, 'string');
assert.ok(result.tz.length > 0);
});
test('empty argument object produces all-defaults payload', () => {
const sb = makeSandbox();
const result = sb.autoSyncWeeklyTrigger({});
assert.equal(result.time, '09:00');
deepShapeEqual(result.days, []);
assert.equal(typeof result.tz, 'string');
});
test('non-array days param defaults to empty', () => {
const sb = makeSandbox();
const result = sb.autoSyncWeeklyTrigger({
time: '09:00', days: 'mon', tz: 'UTC',
});
deepShapeEqual(result.days, []);
});
});
describe('autoSyncWeeklyFromTrigger', () => {
test('round-trips with autoSyncWeeklyTrigger when days non-empty', () => {
const sb = makeSandbox();
const cfg = sb.autoSyncWeeklyTrigger({
time: '09:00', days: ['mon', 'wed'], tz: 'UTC',
});
const parsed = sb.autoSyncWeeklyFromTrigger(cfg);
deepShapeEqual(parsed, {
time: '09:00', days: ['mon', 'wed'], tz: 'UTC',
});
});
test('empty days list expands to every weekday', () => {
const sb = makeSandbox();
// Matches the next_run_at convention: empty days = every day.
// UI needs the expanded form so the schedule renders under all
// 7 day columns instead of looking unscheduled.
const parsed = sb.autoSyncWeeklyFromTrigger({
time: '14:00', days: [], tz: 'UTC',
});
deepShapeEqual(parsed.days,
['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
});
test('uppercased / mixed-case day abbreviations normalised', () => {
const sb = makeSandbox();
const parsed = sb.autoSyncWeeklyFromTrigger({
time: '09:00', days: ['MON', 'Wed'], tz: 'UTC',
});
deepShapeEqual(parsed.days, ['mon', 'wed']);
});
test('null / undefined config returns null', () => {
const sb = makeSandbox();
assert.equal(sb.autoSyncWeeklyFromTrigger(null), null);
assert.equal(sb.autoSyncWeeklyFromTrigger(undefined), null);
});
test('garbage time falls back to 09:00', () => {
const sb = makeSandbox();
const parsed = sb.autoSyncWeeklyFromTrigger({
time: 'garbage', days: ['mon'], tz: 'UTC',
});
assert.equal(parsed.time, '09:00');
});
test('missing tz defaults to UTC (not browser tz)', () => {
// Trigger configs persisted in the DB without a tz field came
// from the legacy engine path that used server-local naive
// ``datetime.now()``. PR 2 routes those through the engine's
// ``_default_tz``, NOT the browser's. So parse-back must surface
// a stable default (UTC) — the UI should NOT silently rewrite
// an existing row's tz when the user opens the editor.
const sb = makeSandbox();
const parsed = sb.autoSyncWeeklyFromTrigger({
time: '09:00', days: ['mon'],
});
assert.equal(parsed.tz, 'UTC');
});
});
describe('buildAutoSyncScheduleState — weekly_time bucketing', () => {
test('weekly_time owned automation lands in weeklySchedules', () => {
const sb = makeSandbox();
const playlists = [{ id: 7, name: 'Daily Mix', source: 'spotify' }];
const automations = [{
id: 42,
name: 'Auto-Sync: Daily Mix',
enabled: true,
owned_by: 'auto_sync',
action_type: 'playlist_pipeline',
action_config: { playlist_id: '7', all: false },
trigger_type: 'weekly_time',
trigger_config: { time: '09:00', days: ['mon', 'wed', 'fri'], tz: 'America/Los_Angeles' },
next_run: '2026-06-01 16:00:00',
}];
const state = sb.buildAutoSyncScheduleState(playlists, automations);
assert.ok(state.weeklySchedules);
const sched = state.weeklySchedules[7];
assert.ok(sched, 'weekly schedule must surface in state.weeklySchedules[playlistId]');
assert.equal(sched.automation_id, 42);
assert.equal(sched.time, '09:00');
deepShapeEqual(sched.days, ['mon', 'wed', 'fri']);
assert.equal(sched.tz, 'America/Los_Angeles');
// And NOT in playlistSchedules (mutual exclusion at the bucket level).
assert.equal(state.playlistSchedules[7], undefined);
});
test('schedule (interval) automation stays in playlistSchedules', () => {
const sb = makeSandbox();
const playlists = [{ id: 7, name: 'Daily Mix', source: 'spotify' }];
const automations = [{
id: 42,
owned_by: 'auto_sync',
action_type: 'playlist_pipeline',
action_config: { playlist_id: '7', all: false },
trigger_type: 'schedule',
trigger_config: { interval: 6, unit: 'hours' },
enabled: true,
}];
const state = sb.buildAutoSyncScheduleState(playlists, automations);
assert.ok(state.playlistSchedules[7]);
assert.equal(state.weeklySchedules[7], undefined);
});
test('non-owned weekly_time automation falls through to automationPipelines', () => {
// Backward-compat: a hand-created weekly_time automation
// NOT owned by auto_sync must NOT hijack the Weekly Board
// — it stays as a regular automation pipeline visible on
// the Automation Pipelines tab.
const sb = makeSandbox();
const playlists = [{ id: 7, name: 'Daily Mix', source: 'spotify' }];
const automations = [{
id: 99,
name: 'My Custom Weekly Thing',
// No owned_by, no Auto-Sync: prefix, no Playlist Auto-Sync group.
action_type: 'playlist_pipeline',
action_config: { playlist_id: '7', all: false },
trigger_type: 'weekly_time',
trigger_config: { time: '09:00', days: ['mon'], tz: 'UTC' },
enabled: true,
}];
const state = sb.buildAutoSyncScheduleState(playlists, automations);
assert.equal(state.weeklySchedules[7], undefined);
assert.equal(state.playlistSchedules[7], undefined);
assert.equal(state.automationPipelines.length, 1);
assert.equal(state.automationPipelines[0].id, 99);
});
test('legacy-named (Auto-Sync: prefix) weekly_time still recognised', () => {
// Rows pre-dating the owned_by column should still be picked
// up by the legacy name/group fallback.
const sb = makeSandbox();
const playlists = [{ id: 7, name: 'Daily Mix', source: 'spotify' }];
const automations = [{
id: 99,
name: 'Auto-Sync: Daily Mix', // legacy convention
group_name: 'Playlist Auto-Sync',
action_type: 'playlist_pipeline',
action_config: { playlist_id: '7', all: false },
trigger_type: 'weekly_time',
trigger_config: { time: '09:00', days: ['mon'], tz: 'UTC' },
enabled: true,
}];
const state = sb.buildAutoSyncScheduleState(playlists, automations);
assert.ok(state.weeklySchedules[7], 'legacy-named auto-sync row should bucket weekly');
});
test('garbage weekly_time config falls through to automationPipelines', () => {
// Defensive — a hand-edited row with malformed trigger_config
// should NOT crash state-build. autoSyncWeeklyFromTrigger
// returns null for non-object configs; the bucket logic
// routes nulls to automationPipelines as the catch-all.
const sb = makeSandbox();
const playlists = [{ id: 7, name: 'Daily Mix', source: 'spotify' }];
const automations = [{
id: 42,
owned_by: 'auto_sync',
action_type: 'playlist_pipeline',
action_config: { playlist_id: '7', all: false },
trigger_type: 'weekly_time',
trigger_config: null,
enabled: true,
}];
const state = sb.buildAutoSyncScheduleState(playlists, automations);
assert.equal(state.weeklySchedules[7], undefined);
assert.equal(state.automationPipelines.length, 1);
});
});
describe('autoSyncWeeklyLabel', () => {
test('multi-day schedule renders ordered day list with time', () => {
const sb = makeSandbox();
// Input intentionally in non-canonical order to verify sort.
const label = sb.autoSyncWeeklyLabel({
time: '09:00', days: ['fri', 'mon', 'wed'], tz: 'UTC',
});
assert.equal(label, 'Mon, Wed, Fri @ 09:00');
});
test('full-week schedule collapses to Daily', () => {
const sb = makeSandbox();
const label = sb.autoSyncWeeklyLabel({
time: '14:30',
days: ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'],
tz: 'UTC',
});
assert.equal(label, 'Daily @ 14:30');
});
test('single-day schedule shows just that day', () => {
const sb = makeSandbox();
const label = sb.autoSyncWeeklyLabel({
time: '20:00', days: ['sun'], tz: 'UTC',
});
assert.equal(label, 'Sun @ 20:00');
});
test('null parsed value returns Unscheduled', () => {
const sb = makeSandbox();
assert.equal(sb.autoSyncWeeklyLabel(null), 'Unscheduled');
});
test('empty days array treated as daily (matches engine semantic)', () => {
const sb = makeSandbox();
const label = sb.autoSyncWeeklyLabel({
time: '09:00', days: [], tz: 'UTC',
});
assert.equal(label, 'Daily @ 09:00');
});
});

View file

@ -16,6 +16,7 @@ let _autoSyncScheduleState = {
playlists: [],
automations: [],
playlistSchedules: {},
weeklySchedules: {},
automationPipelines: [],
runHistory: [],
runHistoryTotal: 0,
@ -24,6 +25,11 @@ let _autoSyncActiveTab = 'schedule';
let _autoSyncSidebarFilter = '';
let _autoSyncHistoryFilter = 'all'; // 'all' | 'error' | 'completed' | 'skipped'
let _autoSyncHistoryLimit = 50;
// Open weekly-editor popover state. ``null`` when no popover is open.
// Tracks playlist id + the current draft (time / days / tz) so the
// editor is a controlled component — clicking outside without saving
// discards the draft.
let _autoSyncWeeklyEditor = null;
function getMirroredSourceRef(p) {
if (p && p.source_ref) return String(p.source_ref);
@ -67,6 +73,76 @@ function autoSyncIntervalLabel(hours) {
return `Every ${hours} hour${hours === 1 ? '' : 's'}`;
}
// 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).
function detectBrowserTimezone() {
try {
const tz = typeof Intl !== 'undefined'
&& Intl.DateTimeFormat
&& Intl.DateTimeFormat().resolvedOptions().timeZone;
return tz || 'UTC';
} catch (_) {
return 'UTC';
}
}
// Canonical weekday order Mon-Sun. Matches both the backend
// ``next_run_at`` weekday_map and the column ordering in the UI.
// Keeping the abbreviations short-lowercase ('mon' not 'MON' / 'Mon')
// matches the engine's existing config payload convention.
const AUTO_SYNC_WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
const AUTO_SYNC_WEEKDAY_LABELS = {
mon: 'Mon', tue: 'Tue', wed: 'Wed', thu: 'Thu',
fri: 'Fri', sat: 'Sat', sun: 'Sun',
};
// Build a ``weekly_time`` trigger_config payload from picker input.
// Defensive — caller may pass garbage; we clamp / drop / default so
// the resulting payload always passes ``next_run_at`` validation.
function autoSyncWeeklyTrigger({ time, days, tz } = {}) {
const safeTime = typeof time === 'string' && /^\d{1,2}:\d{2}$/.test(time)
? time : '09:00';
const safeDays = Array.isArray(days)
? days.filter(d => AUTO_SYNC_WEEKDAYS.includes(d))
: [];
const safeTz = (typeof tz === 'string' && tz) ? tz : detectBrowserTimezone();
return { time: safeTime, days: safeDays, tz: safeTz };
}
// Parse the days/time/tz back out of a ``weekly_time`` trigger_config,
// with defensive fallbacks so a hand-edited row doesn't crash render.
// Returns null when the config isn't recognisable as a weekly trigger.
function autoSyncWeeklyFromTrigger(config) {
if (!config || typeof config !== 'object') return null;
const rawTime = typeof config.time === 'string' && /^\d{1,2}:\d{2}$/.test(config.time)
? config.time : '09:00';
let days = Array.isArray(config.days)
? config.days.map(d => String(d).toLowerCase()).filter(d => AUTO_SYNC_WEEKDAYS.includes(d))
: [];
// Empty / all-invalid days = "every day" per next_run_at convention.
// Surface that so the UI can render the schedule under all 7 day
// columns instead of treating it as unscheduled.
if (days.length === 0) days = [...AUTO_SYNC_WEEKDAYS];
const tz = (typeof config.tz === 'string' && config.tz) ? config.tz : 'UTC';
return { time: rawTime, days, tz };
}
// Human-readable label for a weekly schedule. Used on card metadata
// and column tooltips. Multi-day schedules collapse to "Mon, Wed, Fri
// @09:00"; full-week schedules collapse to "Daily @ 09:00".
function autoSyncWeeklyLabel(parsed) {
if (!parsed) return 'Unscheduled';
const { time, days } = parsed;
if (!Array.isArray(days) || days.length === 0) return `Daily @ ${time}`;
if (days.length === 7) return `Daily @ ${time}`;
// Sort to canonical Mon-Sun order so card text doesn't shuffle
// when the user toggles days on/off in arbitrary order.
const ordered = AUTO_SYNC_WEEKDAYS.filter(d => days.includes(d));
const dayList = ordered.map(d => AUTO_SYNC_WEEKDAY_LABELS[d]).join(', ');
return `${dayList} @ ${time}`;
}
function autoSyncSourceLabel(source) {
const labels = {
spotify: 'Spotify',
@ -125,29 +201,58 @@ function autoSyncIsScheduleOwned(auto) {
function buildAutoSyncScheduleState(playlists, automations, historyData = {}) {
const playlistSchedules = {};
const weeklySchedules = {};
const automationPipelines = [];
const pipelineAutomations = automations.filter(autoSyncIsPipelineAutomation);
pipelineAutomations.forEach(auto => {
const playlistId = autoSyncPlaylistIdFromAutomation(auto);
const hours = auto.trigger_type === 'schedule' ? autoSyncHoursFromTrigger(auto.trigger_config || {}) : null;
if (playlistId && hours && autoSyncIsScheduleOwned(auto)) {
playlistSchedules[playlistId] = {
automation_id: auto.id,
automation_name: auto.name,
hours,
enabled: auto.enabled !== false && auto.enabled !== 0,
owned: true,
next_run: auto.next_run,
trigger_config: auto.trigger_config || {},
};
} else {
automationPipelines.push(auto);
const isOwned = autoSyncIsScheduleOwned(auto);
if (playlistId && isOwned && auto.trigger_type === 'schedule') {
const hours = autoSyncHoursFromTrigger(auto.trigger_config || {});
if (hours) {
playlistSchedules[playlistId] = {
automation_id: auto.id,
automation_name: auto.name,
hours,
enabled: auto.enabled !== false && auto.enabled !== 0,
owned: true,
next_run: auto.next_run,
trigger_config: auto.trigger_config || {},
};
return;
}
}
if (playlistId && isOwned && auto.trigger_type === 'weekly_time') {
// No ``|| {}`` coercion here on purpose — null / non-object
// trigger_config from a hand-edited row should fall through
// to automationPipelines as a "broken row" rather than be
// silently bucketed as an every-day schedule. The helper
// returns null for those cases; truthy config flows through
// the helper's defensive defaults.
const parsed = autoSyncWeeklyFromTrigger(auto.trigger_config);
if (parsed) {
weeklySchedules[playlistId] = {
automation_id: auto.id,
automation_name: auto.name,
time: parsed.time,
days: parsed.days,
tz: parsed.tz,
enabled: auto.enabled !== false && auto.enabled !== 0,
owned: true,
next_run: auto.next_run,
trigger_config: auto.trigger_config || {},
};
return;
}
}
automationPipelines.push(auto);
});
return {
playlists,
automations,
playlistSchedules,
weeklySchedules,
automationPipelines,
runHistory: historyData.history || [],
runHistoryTotal: historyData.total || 0,
@ -220,16 +325,19 @@ function renderAutoSyncScheduleModal() {
const overlay = document.getElementById('auto-sync-schedule-modal');
if (!overlay) return;
const { playlists, playlistSchedules, automationPipelines, runHistory, runHistoryTotal } = _autoSyncScheduleState;
const scheduledCount = Object.keys(playlistSchedules).length;
const enabledCount = Object.values(playlistSchedules).filter(s => s.enabled).length;
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
+ Object.values(weeklySchedules || {}).filter(s => s.enabled).length;
const pipelineCount = automationPipelines.length;
const totalTracks = playlists.reduce((sum, p) => sum + (parseInt(p.track_count, 10) || 0), 0);
const scheduleActive = _autoSyncActiveTab === 'schedule';
const weeklyActive = _autoSyncActiveTab === 'weekly';
const automationsActive = _autoSyncActiveTab === 'automations';
const historyActive = _autoSyncActiveTab === 'history';
const schedulePanel = renderAutoSyncSchedulePanel(playlists, playlistSchedules);
const weeklyPanel = renderAutoSyncWeeklyPanel(playlists, playlistSchedules);
const automationPanel = renderAutoSyncAutomationPanel(automationPipelines, playlists);
const historyPanel = renderAutoSyncHistoryPanel(runHistory, runHistoryTotal);
const monitor = renderAutoSyncPipelineMonitor(playlists);
@ -252,7 +360,8 @@ function renderAutoSyncScheduleModal() {
</div>
${monitor}
<div class="auto-sync-tabs">
<button class="${scheduleActive ? 'active' : ''}" onclick="setAutoSyncTab('schedule')">Schedule Board</button>
<button class="${scheduleActive ? 'active' : ''}" onclick="setAutoSyncTab('schedule')">Hourly Board</button>
<button class="${weeklyActive ? 'active' : ''}" onclick="setAutoSyncTab('weekly')">Weekly Board</button>
<button class="${automationsActive ? 'active' : ''}" onclick="setAutoSyncTab('automations')">Automation Pipelines</button>
<button class="${historyActive ? 'active' : ''}" onclick="setAutoSyncTab('history')">
Run History
@ -263,6 +372,7 @@ function renderAutoSyncScheduleModal() {
</button>
</div>
<div class="auto-sync-tab-panel ${scheduleActive ? 'active' : ''}" id="auto-sync-schedule-panel">${schedulePanel}</div>
<div class="auto-sync-tab-panel ${weeklyActive ? 'active' : ''}" id="auto-sync-weekly-panel">${weeklyPanel}</div>
<div class="auto-sync-tab-panel ${automationsActive ? 'active' : ''}" id="auto-sync-automation-panel">${automationPanel}</div>
<div class="auto-sync-tab-panel ${historyActive ? 'active' : ''}" id="auto-sync-history-panel">${historyPanel}</div>
</div>
@ -272,7 +382,11 @@ function renderAutoSyncScheduleModal() {
}
function setAutoSyncTab(tab) {
_autoSyncActiveTab = ['automations', 'history'].includes(tab) ? tab : 'schedule';
const allowed = ['schedule', 'weekly', 'automations', 'history'];
_autoSyncActiveTab = allowed.includes(tab) ? tab : 'schedule';
// Switching tabs closes any open weekly editor so the popover
// doesn't ghost-render over the wrong panel.
if (_autoSyncActiveTab !== 'weekly') _autoSyncWeeklyEditor = null;
renderAutoSyncScheduleModal();
}
@ -373,6 +487,199 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) {
`;
}
// ──────────────────────────────────────────────────────────────────────
// Weekly schedule board — PR 3 of the schedule-types feature.
// Renders 7 day columns Mon-Sun. Each column lists the playlists
// scheduled to run on that weekday (multi-day schedules render
// under EACH matching column, matching how the user thinks about
// "this playlist runs on Mon AND Wed AND Fri"). Drag a playlist onto
// a column → create a single-day weekly schedule at the default time.
// Click a scheduled card → opens an editor popover for time + days
// + tz adjustments.
// ──────────────────────────────────────────────────────────────────────
function renderAutoSyncWeeklyPanel(playlists, playlistSchedules) {
const weeklySchedules = _autoSyncScheduleState.weeklySchedules || {};
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] = [];
acc[key].push(p);
return acc;
}, {});
const sourceKeys = Object.keys(grouped).sort((a, b) => autoSyncSourceLabel(a).localeCompare(autoSyncSourceLabel(b)));
const sidebarHtml = sourceKeys.length ? sourceKeys.map(source => `
<div class="auto-sync-source-group">
<div class="auto-sync-source-group-head">
<span class="auto-sync-source-title">${_esc(autoSyncSourceLabel(source))}</span>
</div>
${grouped[source].map(p => {
const weekly = weeklySchedules[p.id];
const hourly = playlistSchedules[p.id];
let assigned = 'Unscheduled';
if (weekly) assigned = autoSyncWeeklyLabel(weekly);
else if (hourly) assigned = `Hourly (${autoSyncIntervalLabel(hourly.hours).toLowerCase()})`;
return `
<div class="auto-sync-playlist ${weekly ? 'scheduled' : (hourly ? 'scheduled-elsewhere' : '')}"
draggable="true" data-playlist-id="${p.id}"
ondragstart="autoSyncWeeklyDragStart(event)" ondragend="autoSyncWeeklyDragEnd()">
<div class="auto-sync-playlist-name">${_esc(p.name)}</div>
<div class="auto-sync-playlist-meta">${p.track_count || 0} tracks &middot; ${_esc(assigned)}</div>
</div>
`;
}).join('')}
</div>
`).join('') : '<div class="auto-sync-empty">No refreshable mirrored playlists yet.</div>';
const unavailableHtml = unavailablePlaylists.length ? `
<div class="auto-sync-source-group auto-sync-source-group-disabled">
<div class="auto-sync-source-title">Not schedulable</div>
${unavailablePlaylists.map(p => `
<div class="auto-sync-playlist unavailable">
<div class="auto-sync-playlist-name">${_esc(p.name)}</div>
<div class="auto-sync-playlist-meta">${_esc(autoSyncSourceLabel(p.source))} &middot; refresh not supported</div>
</div>
`).join('')}
</div>
` : '';
// Build per-day column lists. Iterate the weeklySchedules dict
// once instead of per-day scanning so multi-day schedules render
// under each matching column without a double-loop.
const playlistsById = new Map(schedulablePlaylists.map(p => [parseInt(p.id, 10), p]));
const cardsByDay = {};
AUTO_SYNC_WEEKDAYS.forEach(d => { cardsByDay[d] = []; });
Object.entries(weeklySchedules).forEach(([pid, sched]) => {
const playlist = playlistsById.get(parseInt(pid, 10));
if (!playlist) return;
(sched.days || []).forEach(day => {
if (cardsByDay[day]) cardsByDay[day].push({ playlist, schedule: sched });
});
});
const dayColumnsHtml = AUTO_SYNC_WEEKDAYS.map(day => {
const cards = cardsByDay[day];
const cardHtml = cards.length
? 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>';
return `
<div class="auto-sync-column auto-sync-weekly-column" 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>
<div class="auto-sync-column-list">${cardHtml}</div>
</div>
`;
}).join('');
const filterValue = _esc(_autoSyncSidebarFilter || '');
const editorHtml = _autoSyncWeeklyEditor ? renderAutoSyncWeeklyEditor() : '';
return `
<div class="auto-sync-board-intro">
<div>
<strong>Drag playlists onto a day</strong>
<span>Each placement creates a weekly-time schedule. Click a card to edit time, additional days, or timezone.</span>
</div>
<button onclick="refreshAutoSyncScheduleModal()">Refresh</button>
</div>
<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 auto-sync-weekly-board">${dayColumnsHtml}</main>
</div>
${editorHtml}
`;
}
function autoSyncWeeklyCardHtml(playlist, schedule) {
const enabled = schedule.enabled !== false;
const label = autoSyncWeeklyLabel(schedule);
return `
<div class="auto-sync-scheduled-card auto-sync-weekly-card ${enabled ? '' : 'disabled'}"
data-playlist-id="${playlist.id}"
onclick="openAutoSyncWeeklyEditor(${playlist.id})">
<div class="auto-sync-scheduled-name">${_esc(playlist.name)}</div>
<div class="auto-sync-scheduled-meta">
<span>${_esc(label)}</span>
<small>${_esc(schedule.tz || 'UTC')}</small>
</div>
</div>
`;
}
function renderAutoSyncWeeklyEditor() {
const draft = _autoSyncWeeklyEditor;
if (!draft) return '';
const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(draft.playlistId, 10));
if (!playlist) return '';
const dayToggles = AUTO_SYNC_WEEKDAYS.map(day => {
const on = draft.days.includes(day);
return `
<button type="button"
class="auto-sync-weekly-day-toggle ${on ? 'active' : ''}"
onclick="toggleAutoSyncWeeklyEditorDay('${day}')">
${AUTO_SYNC_WEEKDAY_LABELS[day]}
</button>
`;
}).join('');
const existing = _autoSyncScheduleState.weeklySchedules?.[draft.playlistId];
return `
<div class="auto-sync-weekly-editor-backdrop" onclick="closeAutoSyncWeeklyEditor()">
<div class="auto-sync-weekly-editor" onclick="event.stopPropagation()">
<div class="auto-sync-weekly-editor-head">
<h4>Weekly schedule</h4>
<button type="button" class="auto-sync-close" onclick="closeAutoSyncWeeklyEditor()">&times;</button>
</div>
<div class="auto-sync-weekly-editor-playlist">${_esc(playlist.name)}</div>
<div class="auto-sync-weekly-editor-section">
<label>Days</label>
<div class="auto-sync-weekly-editor-days">${dayToggles}</div>
</div>
<div class="auto-sync-weekly-editor-section">
<label for="auto-sync-weekly-time">Time</label>
<input type="time" id="auto-sync-weekly-time"
value="${_escAttr(draft.time)}"
oninput="setAutoSyncWeeklyEditorTime(this.value)" />
</div>
<div class="auto-sync-weekly-editor-section">
<label for="auto-sync-weekly-tz">Timezone (IANA)</label>
<input type="text" id="auto-sync-weekly-tz"
value="${_escAttr(draft.tz)}"
oninput="setAutoSyncWeeklyEditorTz(this.value)" />
<small class="auto-sync-weekly-editor-hint">e.g. America/Los_Angeles, Europe/London, Asia/Tokyo</small>
</div>
<div class="auto-sync-weekly-editor-actions">
${existing ? `<button class="auto-sync-weekly-editor-delete" onclick="unscheduleAutoSyncWeeklyFromEditor()">Unschedule</button>` : ''}
<div class="auto-sync-weekly-editor-actions-right">
<button class="auto-sync-weekly-editor-cancel" onclick="closeAutoSyncWeeklyEditor()">Cancel</button>
<button class="auto-sync-weekly-editor-save" onclick="saveAutoSyncWeeklyFromEditor()">Save</button>
</div>
</div>
</div>
</div>
`;
}
function setAutoSyncSidebarFilter(value) {
_autoSyncSidebarFilter = String(value || '');
// Only re-render the sidebar/board portion to keep input focus.
@ -1311,6 +1618,17 @@ async function saveAutoSyncPlaylistSchedule(playlistId, hours) {
showToast('That playlist source cannot be refreshed by Auto-Sync.', 'info');
return;
}
// Enforce one-schedule-per-playlist: if a weekly schedule exists,
// drop it before installing the hourly one. Mirrors the same
// mutual-exclusion the weekly save path enforces in reverse.
const existingWeekly = _autoSyncScheduleState.weeklySchedules?.[playlistId];
if (existingWeekly) {
try {
await fetch(`/api/automations/${existingWeekly.automation_id}`, { method: 'DELETE' });
} catch (_) { /* best-effort cleanup */ }
}
const existing = _autoSyncScheduleState.playlistSchedules[playlistId];
const payload = {
name: `Auto-Sync: ${playlist.name}`,
@ -1353,6 +1671,205 @@ async function unscheduleAutoSyncPlaylist(playlistId) {
}
}
// ──────────────────────────────────────────────────────────────────────
// Weekly-tab drag-drop + editor state mutators.
// ──────────────────────────────────────────────────────────────────────
function autoSyncWeeklyDragStart(event) {
_autoSyncIsDragging = true;
const id = event.currentTarget?.dataset?.playlistId || '';
event.dataTransfer.setData('text/plain', id);
event.dataTransfer.effectAllowed = 'move';
}
function autoSyncWeeklyDragEnd() {
_autoSyncIsDragging = false;
}
function autoSyncWeeklyDragOver(event) {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
const col = event.currentTarget;
if (col && !col.classList.contains('drag-over')) {
col.classList.add('drag-over');
}
}
function autoSyncWeeklyDragLeave(event) {
const col = event.currentTarget;
if (!col) return;
col.classList.remove('drag-over');
}
async function autoSyncWeeklyDrop(event, day) {
event.preventDefault();
_autoSyncIsDragging = false;
const col = event.currentTarget;
if (col) col.classList.remove('drag-over');
const playlistId = parseInt(event.dataTransfer.getData('text/plain'), 10);
if (!playlistId) return;
if (!AUTO_SYNC_WEEKDAYS.includes(day)) return;
const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === playlistId);
if (!playlist || !autoSyncCanSchedulePlaylist(playlist)) {
showToast('That playlist source cannot be refreshed by Auto-Sync.', 'info');
return;
}
// Augment OR create: if a weekly schedule already exists for this
// playlist, append the dropped day (no-op when already present);
// otherwise create a single-day schedule with default time + browser tz.
const existing = _autoSyncScheduleState.weeklySchedules?.[playlistId];
const days = existing
? (existing.days.includes(day) ? existing.days : [...existing.days, day])
: [day];
const time = existing?.time || '09:00';
const tz = existing?.tz || detectBrowserTimezone();
await saveAutoSyncWeeklySchedule(playlistId, { time, days, tz });
}
function openAutoSyncWeeklyEditor(playlistId) {
const pid = parseInt(playlistId, 10);
if (!pid) return;
const existing = _autoSyncScheduleState.weeklySchedules?.[pid];
_autoSyncWeeklyEditor = {
playlistId: pid,
time: existing?.time || '09:00',
days: existing ? [...existing.days] : [],
tz: existing?.tz || detectBrowserTimezone(),
};
renderAutoSyncScheduleModal();
}
function closeAutoSyncWeeklyEditor() {
_autoSyncWeeklyEditor = null;
renderAutoSyncScheduleModal();
}
function toggleAutoSyncWeeklyEditorDay(day) {
if (!_autoSyncWeeklyEditor) return;
if (!AUTO_SYNC_WEEKDAYS.includes(day)) return;
const idx = _autoSyncWeeklyEditor.days.indexOf(day);
if (idx >= 0) {
_autoSyncWeeklyEditor.days.splice(idx, 1);
} else {
_autoSyncWeeklyEditor.days.push(day);
}
renderAutoSyncScheduleModal();
}
function setAutoSyncWeeklyEditorTime(value) {
if (!_autoSyncWeeklyEditor) return;
_autoSyncWeeklyEditor.time = String(value || '09:00');
}
function setAutoSyncWeeklyEditorTz(value) {
if (!_autoSyncWeeklyEditor) return;
_autoSyncWeeklyEditor.tz = String(value || 'UTC');
}
async function saveAutoSyncWeeklyFromEditor() {
if (!_autoSyncWeeklyEditor) return;
const { playlistId, time, days, tz } = _autoSyncWeeklyEditor;
if (!days.length) {
showToast('Pick at least one day for the weekly schedule.', 'error');
return;
}
await saveAutoSyncWeeklySchedule(playlistId, { time, days, tz });
_autoSyncWeeklyEditor = null;
}
async function unscheduleAutoSyncWeeklyFromEditor() {
if (!_autoSyncWeeklyEditor) return;
const { playlistId } = _autoSyncWeeklyEditor;
_autoSyncWeeklyEditor = null;
await unscheduleAutoSyncWeekly(playlistId);
}
async function saveAutoSyncWeeklySchedule(playlistId, { time, days, tz }) {
const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10));
if (!playlist) return;
if (!autoSyncCanSchedulePlaylist(playlist)) {
showToast('That playlist source cannot be refreshed by Auto-Sync.', 'info');
return;
}
const triggerConfig = autoSyncWeeklyTrigger({ time, days, tz });
if (!triggerConfig.days.length) {
showToast('Pick at least one day for the weekly schedule.', 'error');
return;
}
// Enforce one-schedule-per-playlist: if the playlist currently has
// an hourly schedule, drop it before installing the weekly one. The
// engine can technically run both side-by-side as two separate
// automations, but the UI assumes one schedule per playlist and
// showing two cards under the same playlist row would surprise
// users. Delete-then-create is safe — the worst case (POST fails)
// leaves the playlist unscheduled, which is recoverable from the UI.
const existingHourly = _autoSyncScheduleState.playlistSchedules?.[playlistId];
if (existingHourly) {
try {
await fetch(`/api/automations/${existingHourly.automation_id}`, { method: 'DELETE' });
} catch (_) { /* best-effort cleanup */ }
}
const existingWeekly = _autoSyncScheduleState.weeklySchedules?.[playlistId];
const payload = {
name: `Auto-Sync: ${playlist.name}`,
trigger_type: 'weekly_time',
trigger_config: triggerConfig,
action_type: 'playlist_pipeline',
action_config: { playlist_id: String(playlistId), all: false },
then_actions: [],
group_name: 'Playlist Auto-Sync',
owned_by: 'auto_sync',
};
try {
const res = await fetch(existingWeekly ? `/api/automations/${existingWeekly.automation_id}` : '/api/automations', {
method: existingWeekly ? '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 to save weekly schedule');
showToast(`${playlist.name} scheduled ${autoSyncWeeklyLabel(triggerConfig).toLowerCase()}`, 'success');
await refreshAutoSyncScheduleModal();
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
}
}
async function unscheduleAutoSyncWeekly(playlistId) {
const schedule = _autoSyncScheduleState.weeklySchedules?.[playlistId];
const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10));
if (!schedule) return;
if (!await showConfirmDialog({ title: 'Remove Weekly Schedule', message: `Remove weekly schedule for "${playlist?.name || 'this playlist'}"?` })) return;
try {
const res = await fetch(`/api/automations/${schedule.automation_id}`, { method: 'DELETE' });
const data = await res.json();
if (!res.ok || data.error) throw new Error(data.error || 'Failed to remove weekly schedule');
showToast('Weekly schedule removed', 'success');
await refreshAutoSyncScheduleModal();
} catch (err) {
showToast(`Error: ${err.message}`, 'error');
}
}
async function runAutoSyncScheduledPlaylist(playlistId) {
const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10));
if (!playlist) return;

View file

@ -3415,6 +3415,7 @@ function closeHelperSearch() {
const WHATS_NEW = {
'2.6.3': [
{ unreleased: true },
{ title: 'Auto-Sync: schedule playlists by weekday + time, not just by hour interval', desc: 'new Weekly Board tab on the Auto-Sync manager. drag a playlist onto a day column (Mon-Sun) to schedule it for that weekday at the default time, then click the card to open an editor for multi-day picks, custom time, and timezone. existing hourly schedules stay on the Hourly Board tab — one playlist gets exactly one schedule so swapping between weekly and hourly auto-replaces the old one. timezone defaults to whatever your browser reports (e.g. America/Los_Angeles); the editor accepts any IANA tz string. backend already had the plumbing from earlier 2.6.3 commits — this lights up the UI.', page: 'automations' },
{ title: 'Fix: usenet album downloads stuck on "downloading release" even after SAB finished', desc: 'when SAB moves a job from its queue into history (post-processing handoff — verify + unrar window), there\'s a brief gap where the job is in neither. SoulSync\'s poll treated one missing read as terminal failure and gave up silently, leaving the download modal frozen on "downloading release" forever. SAB queue states like `Pp` (post-processing) also weren\'t in the state map, hit the default-error fallback, and hung the poll the same way. now the poll tolerates ~10 seconds of transient misses, treats unmapped queue states as transient instead of silently looping, fires a terminal failed event on every failure path so the modal exits the downloading state, and queries SAB by nzo_id directly instead of paging the last-50-entries history (avoids losing the job on busy servers). same tolerance applies to torrent downloads — covers network blips that used to kill an otherwise healthy download.' },
{ title: 'Discogs: strip artist disambiguation suffixes everywhere', desc: 'Discogs marks duplicate-named artists with either `(N)` numeric suffixes ("Bullet (2)") or trailing asterisks ("John Smith*"). Only the `(N)` form was stripped, and only on the collection-import path — so artist + album search results in the UI surfaced raw "Foo*" or "Bar (2)" rows, and worse, import path inherited those characters into folder names on disk. centralized the cleanup into a single helper applied at every site a Discogs payload becomes a name string (artist search, album search, track lookups, get_artist_albums feature filtering). closes #634.' },
{ title: 'Library: Enhanced / Standard view toggle now sticks per browser', desc: 'the artist detail page used to revert to Standard view every time you clicked into a new artist — flipping to Enhanced was a per-click thing with no memory. now the choice is persisted in localStorage and reapplied automatically on every artist open. survives page reloads too. non-admins (who can\'t toggle Enhanced anyway) are unaffected; admins who never touched the Enhanced view stay on Standard since the saved value defaults to it.' },

View file

@ -12310,6 +12310,226 @@ body.helper-mode-active #dashboard-activity-feed:hover {
color: rgb(var(--accent-neon-rgb));
}
/* ───────────────────────────────────────────────────────────────────── */
/* Weekly schedule board (PR 3 of schedule-types feature) */
/* ───────────────────────────────────────────────────────────────────── */
.auto-sync-weekly-board {
/* 7 day columns slightly narrower than hourly buckets so they fit
without horizontal scroll on the standard modal width.
grid-template-columns: minmax(120px, 1fr) repeated 7 times. */
grid-template-columns: repeat(7, minmax(120px, 1fr));
}
.auto-sync-weekly-column .auto-sync-column-head {
/* Day labels are short ("Mon", "Tue") so the head can breathe. */
text-align: center;
}
.auto-sync-weekly-card {
/* Click-to-edit affordance visually distinct from the hourly
card which is non-interactive aside from the unschedule button. */
cursor: pointer;
}
.auto-sync-weekly-card:hover {
border-color: rgba(var(--accent-rgb), 0.5);
background: rgba(var(--accent-rgb), 0.08);
}
.auto-sync-playlist.scheduled-elsewhere {
/* Visual hint when a playlist has an hourly schedule but the user
is on the weekly tab. Card still draggable; drop creates a
weekly schedule that replaces the hourly one. */
opacity: 0.7;
border-style: dashed;
}
/* ───────────────────────────────────────────────────────────────────── */
/* Weekly editor popover */
/* ───────────────────────────────────────────────────────────────────── */
.auto-sync-weekly-editor-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
z-index: 1050;
display: flex;
align-items: center;
justify-content: center;
animation: autoSyncWeeklyEditorFadeIn 0.15s ease-out;
}
@keyframes autoSyncWeeklyEditorFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.auto-sync-weekly-editor {
background: linear-gradient(160deg,
rgba(15, 20, 32, 0.96) 0%,
rgba(10, 14, 24, 0.96) 100%);
border: 1px solid rgba(var(--accent-rgb), 0.4);
border-radius: 20px;
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.4);
width: 100%;
max-width: 460px;
padding: 24px;
color: rgba(255, 255, 255, 0.9);
font-family: inherit;
}
.auto-sync-weekly-editor-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.auto-sync-weekly-editor-head h4 {
margin: 0;
font-size: 16px;
color: rgba(255, 255, 255, 0.95);
}
.auto-sync-weekly-editor-playlist {
font-size: 13px;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 20px;
padding-bottom: 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.auto-sync-weekly-editor-section {
margin-bottom: 16px;
}
.auto-sync-weekly-editor-section label {
display: block;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(255, 255, 255, 0.5);
margin-bottom: 6px;
}
.auto-sync-weekly-editor-days {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 6px;
}
.auto-sync-weekly-day-toggle {
height: 36px;
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;
font-family: inherit;
transition: all 0.15s ease;
}
.auto-sync-weekly-day-toggle:hover {
border-color: rgba(var(--accent-rgb), 0.35);
background: rgba(var(--accent-rgb), 0.08);
}
.auto-sync-weekly-day-toggle.active {
border-color: rgba(var(--accent-rgb), 0.65);
background: rgba(var(--accent-rgb), 0.2);
color: rgb(var(--accent-light-rgb));
}
.auto-sync-weekly-editor-section input[type="time"],
.auto-sync-weekly-editor-section input[type="text"] {
width: 100%;
height: 36px;
padding: 0 12px;
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.9);
font-size: 13px;
font-family: inherit;
box-sizing: border-box;
}
.auto-sync-weekly-editor-section input:focus {
outline: none;
border-color: rgba(var(--accent-rgb), 0.65);
background: rgba(var(--accent-rgb), 0.06);
}
.auto-sync-weekly-editor-hint {
display: block;
margin-top: 4px;
font-size: 11px;
color: rgba(255, 255, 255, 0.35);
}
.auto-sync-weekly-editor-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 22px;
padding-top: 18px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.auto-sync-weekly-editor-actions-right {
display: flex;
gap: 8px;
}
.auto-sync-weekly-editor-delete,
.auto-sync-weekly-editor-cancel,
.auto-sync-weekly-editor-save {
height: 34px;
padding: 0 16px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
font-size: 12px;
font-weight: 600;
font-family: inherit;
transition: all 0.15s ease;
}
.auto-sync-weekly-editor-save {
background: rgba(var(--accent-rgb), 0.2);
border-color: rgba(var(--accent-rgb), 0.5);
color: rgb(var(--accent-light-rgb));
}
.auto-sync-weekly-editor-save:hover {
background: rgba(var(--accent-rgb), 0.3);
border-color: rgba(var(--accent-rgb), 0.7);
}
.auto-sync-weekly-editor-cancel:hover {
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.9);
}
.auto-sync-weekly-editor-delete {
background: rgba(239, 68, 68, 0.12);
border-color: rgba(239, 68, 68, 0.4);
color: rgba(239, 68, 68, 0.9);
}
.auto-sync-weekly-editor-delete:hover {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.6);
}
.auto-sync-automation-list {
min-height: 0;
overflow-y: auto;