From 698c21c3cef7ef0036c339a3af5b8e6747f6fd13 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 27 May 2026 12:39:56 -0700 Subject: [PATCH] Auto-Sync Weekly Board: weekday schedules in the UI (PR 3/4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/static/test_auto_sync.mjs | 292 +++++++++++++++++ webui/static/auto-sync.js | 553 ++++++++++++++++++++++++++++++-- webui/static/helper.js | 1 + webui/static/style.css | 220 +++++++++++++ 4 files changed, 1048 insertions(+), 18 deletions(-) diff --git a/tests/static/test_auto_sync.mjs b/tests/static/test_auto_sync.mjs index 311be6c2..1eb0fc49 100644 --- a/tests/static/test_auto_sync.mjs +++ b/tests/static/test_auto_sync.mjs @@ -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'); + }); +}); diff --git a/webui/static/auto-sync.js b/webui/static/auto-sync.js index 2dd3b21c..88d981c0 100644 --- a/webui/static/auto-sync.js +++ b/webui/static/auto-sync.js @@ -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() { ${monitor}