Adds the latest successful sync time and sid.is-a.dev to the target playlist description.
";' +
'html+="Advanced";' +
'html+="
"+(EDITOR.mode==="edit"?"":"")+"
";' +
'el("root").innerHTML=html;el("back").onclick=function(){renderHome(STATE);};' +
'el("sourceType").value=c.sourceType;el("behavior").innerHTML=EDITOR.behaviorOptions.map(function(x){return "";}).join("");el("behavior").value=c.behavior;' +
- 'el("jobName").value=c.name||"";el("heartbeat").checked=c.heartbeatEnabled!==false;el("intervalDays").value=c.intervalDays||1;' +
+ 'el("jobName").value=c.name||"";el("heartbeat").checked=c.heartbeatEnabled!==false;el("intervalHours").value=c.intervalHours||1;el("intervalDays").value=c.intervalDays||1;' +
'Array.from(document.querySelectorAll("input[name=automation]")).forEach(function(x){x.checked=x.value===c.automation;x.onchange=toggleAutomation;});' +
'el("sourceType").onchange=toggleEditor;el("targetMode").onchange=toggleEditor;el("sourceSearch").oninput=function(){renderPlaylist("source");};el("targetSearch").oninput=function(){renderPlaylist("target");};' +
'el("sourcePlaylist").onchange=function(){SOURCE_SELECTED=this.value;};el("targetPlaylist").onchange=function(){TARGET_SELECTED=this.value;};' +
'el("save").onclick=saveJob;el("refreshCatalog").onclick=refreshCatalog;if(el("delete"))el("delete").onclick=deleteJob;renderPlaylist("source");renderPlaylist("target");toggleEditor();toggleAutomation();}' +
'function toggleEditor(){el("sourcePlaylistBox").className=el("sourceType").value==="PLAYLIST"?"":"hidden";var create=el("targetMode").value==="create";el("targetExisting").className=create?"hidden":"";el("targetCreate").className=create?"":"hidden";}' +
- 'function currentAutomation(){var x=document.querySelector("input[name=automation]:checked");return x?x.value:"DAILY";}function toggleAutomation(){el("intervalDays").disabled=currentAutomation()!=="INTERVAL";}' +
+ 'function currentAutomation(){var x=document.querySelector("input[name=automation]:checked");return x?x.value:"DAILY";}function toggleAutomation(){var mode=currentAutomation();el("intervalHours").disabled=mode!=="HOURS";el("intervalDays").disabled=mode!=="DAYS";}' +
'function playlistLabel(p){var bits=[p.name];if(p.itemCount)bits.push(p.itemCount+" tracks");if(p.owner)bits.push(p.owner);return bits.join(" · ");}' +
'function renderPlaylist(which){var search=el(which+"Search").value.toLowerCase();var select=el(which+"Playlist");var selected=which==="source"?SOURCE_SELECTED:TARGET_SELECTED;select.innerHTML="";(EDITOR.catalog||[]).filter(function(p){return !search||p.name.toLowerCase().indexOf(search)!==-1||String(p.owner||"").toLowerCase().indexOf(search)!==-1;}).forEach(function(p){var o=document.createElement("option");o.value=p.id;o.textContent=playlistLabel(p);if(p.id===selected)o.selected=true;select.appendChild(o);});}' +
- 'function saveJob(){var button=el("save");button.disabled=true;var payload={jobId:EDITOR.config.jobId||"",name:el("jobName").value,sourceType:el("sourceType").value,sourcePlaylistId:SOURCE_SELECTED,sourceManual:el("sourceManual").value,targetMode:el("targetMode").value,targetPlaylistId:TARGET_SELECTED,targetManual:el("targetManual").value,newTargetName:el("newTargetName").value,targetPublic:el("targetPublic").checked,behavior:el("behavior").value,automation:currentAutomation(),intervalDays:el("intervalDays").value,heartbeatEnabled:el("heartbeat").checked};toast("Saving…","");rpc("spotiSyncSaveJob",[payload],function(r){renderHome(r.home);toast(r.message,r.warning?"warn":"ok");});}' +
+ 'function saveJob(){var button=el("save");button.disabled=true;var payload={jobId:EDITOR.config.jobId||"",name:el("jobName").value,sourceType:el("sourceType").value,sourcePlaylistId:SOURCE_SELECTED,sourceManual:el("sourceManual").value,targetMode:el("targetMode").value,targetPlaylistId:TARGET_SELECTED,targetManual:el("targetManual").value,newTargetName:el("newTargetName").value,targetPublic:el("targetPublic").checked,behavior:el("behavior").value,automation:currentAutomation(),existingFrequency:EDITOR.config.frequency||"Daily",intervalHours:el("intervalHours").value,intervalDays:el("intervalDays").value,heartbeatEnabled:el("heartbeat").checked};toast("Saving…","");rpc("spotiSyncSaveJob",[payload],function(r){renderHome(r.home);toast(r.message,r.warning?"warn":"ok");});}' +
'function refreshCatalog(){toast("Refreshing playlists…","");rpc("spotiSyncRefreshJobCatalog",[],function(list){EDITOR.catalog=list||[];renderPlaylist("source");renderPlaylist("target");toast("Playlist list refreshed.","ok");});}' +
'function deleteJob(){if(!confirm("Delete this Spoti Sync job? This does not delete either Spotify playlist."))return;rpc("spotiSyncDeleteJob",[EDITOR.config.jobId],function(r){renderHome(r.home);toast(r.message,r.warning?"warn":"ok");});}' +
'function runJob(job){if(job.behavior==="Exact Mirror"&&!confirm("Run "+job.name+" now? Exact Mirror may remove tracks from the target so it matches the source."))return;toast("Syncing "+job.name+"…","");rpc("spotiSyncRunJob",[job.jobId],function(r){renderHome(r.home);var j=r.result&&r.result.jobs&&r.result.jobs[0];if(j&&j.status==="Error"){toast(j.error||"Sync failed.","bad");}else if(j){toast("Synced: +"+j.added+" / -"+j.removed+(j.warning?" · "+j.warning:""),j.warning?"warn":"ok");}else{toast("Sync complete.","ok");}});}' +
@@ -125,4 +125,4 @@ var SpotiSync = SpotiSync || {};
_appHtml: appHtml
};
-})(SpotiSync);
+})(SpotiSync);
\ No newline at end of file
From 90ed917f1eaca426043a2a560b4b7c8da8e91190 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Thu, 20 Aug 2026 05:02:57 +0530
Subject: [PATCH 07/22] Add adaptive scheduler regression coverage
---
scripts/test-scheduler.js | 172 ++++++++++++++++++++++++++++++++------
1 file changed, 148 insertions(+), 24 deletions(-)
diff --git a/scripts/test-scheduler.js b/scripts/test-scheduler.js
index fc45c1e..781b7e8 100644
--- a/scripts/test-scheduler.js
+++ b/scripts/test-scheduler.js
@@ -12,7 +12,27 @@ const entrypoints = fs.readFileSync(path.join(root, 'src', '99_Entrypoints.gs'),
const sheetViews = fs.readFileSync(path.join(root, 'src', '65_SheetViews.gs'), 'utf8');
const core = fs.readFileSync(path.join(root, 'src', '00_Core.gs'), 'utf8');
-function makeContext(enabledJobs, initialTriggerCount) {
+function dayJob(id, interval = 1, enabled = true) {
+ return {
+ jobId: id,
+ enabled,
+ frequencyUnit: 'DAY',
+ frequencyInterval: interval,
+ intervalDays: interval
+ };
+}
+
+function hourJob(id, interval = 1, enabled = true) {
+ return {
+ jobId: id,
+ enabled,
+ frequencyUnit: 'HOUR',
+ frequencyInterval: interval,
+ intervalHours: interval
+ };
+}
+
+function makeContext(jobs, initialTriggerCount, initialMode) {
let triggerId = 0;
let triggers = Array.from({ length: initialTriggerCount }, () => ({
id: ++triggerId,
@@ -20,6 +40,14 @@ function makeContext(enabledJobs, initialTriggerCount) {
}));
let creates = 0;
let deletes = 0;
+ let summaryRefreshes = 0;
+ let updateChecks = 0;
+ const createdCadences = [];
+ const documentStatus = {};
+ if (initialMode !== undefined && initialMode !== null) {
+ documentStatus.SCHEDULER_MODE = initialMode;
+ }
+
const context = vm.createContext({
console, Date, Object, Array, String, Number, Boolean, Math, JSON, RegExp, Error,
Session: { getScriptTimeZone() { return 'UTC'; } },
@@ -36,12 +64,23 @@ function makeContext(enabledJobs, initialTriggerCount) {
},
newTrigger(handler) {
assert.strictEqual(handler, 'spotiSyncScheduler');
+ let cadence = '';
return {
timeBased() { return this; },
- everyDays(days) { assert.strictEqual(days, 1); return this; },
+ everyDays(days) {
+ assert.strictEqual(days, 1);
+ cadence = 'DAILY';
+ return this;
+ },
+ everyHours(hours) {
+ assert.strictEqual(hours, 1);
+ cadence = 'HOURLY';
+ return this;
+ },
atHour() { return this; },
create() {
creates += 1;
+ createdCadences.push(cadence);
const trigger = {
id: ++triggerId,
getHandlerFunction() { return 'spotiSyncScheduler'; }
@@ -53,73 +92,158 @@ function makeContext(enabledJobs, initialTriggerCount) {
}
}
});
+
context.SpotiSync = {
- Constants: { DEFAULT_SCHEDULER_HOUR: 3 },
+ Constants: {
+ DEFAULT_SCHEDULER_HOUR: 3,
+ FREQUENCY_UNITS: { HOUR: 'HOUR', DAY: 'DAY' }
+ },
Core: {
+ trim(value) { return value === null || value === undefined ? '' : String(value).trim(); },
nowIso() { return new Date().toISOString(); },
safeErrorMessage(error) { return String(error && error.message || error); }
},
Storage: {
- getDocumentStatus() { return {}; },
- setDocumentStatus() {}
+ getDocumentStatus() { return { ...documentStatus }; },
+ setDocumentStatus(values) { Object.assign(documentStatus, values); }
},
SheetStore: {
- getJobs() {
- return Array.from({ length: enabledJobs }, (_, index) => ({ jobId: `job_${index}`, enabled: true }));
- },
- refreshSummary() {}
+ getJobs() { return jobs.slice(); },
+ refreshSummary() { summaryRefreshes += 1; }
},
- UpdateChecker: { check() {} },
+ UpdateChecker: { check() { updateChecks += 1; } },
SyncEngine: { runDue() { return { status: 'Success' }; } }
};
vm.runInContext(schedulerSource, context, { filename: '80_Scheduler.gs' });
+
return {
Scheduler: context.SpotiSync.Scheduler,
- stats() { return { creates, deletes, triggerCount: triggers.length }; }
+ setRunDueResult(result) { context.SpotiSync.SyncEngine.runDue = () => result; },
+ stats() {
+ return {
+ creates,
+ deletes,
+ triggerCount: triggers.length,
+ createdCadences: createdCadences.slice(),
+ mode: documentStatus.SCHEDULER_MODE || '',
+ summaryRefreshes,
+ updateChecks
+ };
+ }
};
}
(function testZeroAutomatedJobsRemovesTriggers() {
- const env = makeContext(0, 2);
+ const env = makeContext([], 2, 'DAILY');
const result = env.Scheduler.reconcile({ refresh: false });
assert.strictEqual(result.enabled, false);
+ assert.strictEqual(result.mode, 'NONE');
assert.strictEqual(env.stats().triggerCount, 0);
assert.strictEqual(env.stats().creates, 0);
assert.strictEqual(env.stats().deletes, 2);
+ assert.strictEqual(env.stats().mode, 'NONE');
})();
-(function testOneCorrectTriggerIsNotRecreated() {
- const env = makeContext(2, 1);
+(function testLegacySingleDailyTriggerIsRetainedAndMarked() {
+ const env = makeContext([dayJob('job_day')], 1, null);
const result = env.Scheduler.reconcile({ refresh: false });
assert.strictEqual(result.enabled, true);
+ assert.strictEqual(result.mode, 'DAILY');
assert.strictEqual(result.changed, false);
assert.strictEqual(env.stats().triggerCount, 1);
assert.strictEqual(env.stats().creates, 0);
assert.strictEqual(env.stats().deletes, 0);
+ assert.strictEqual(env.stats().mode, 'DAILY');
})();
-(function testMissingTriggerIsCreatedOnce() {
- const env = makeContext(1, 0);
+(function testMissingDailyTriggerIsCreatedOnce() {
+ const env = makeContext([dayJob('job_day', 7)], 0, 'NONE');
const result = env.Scheduler.reconcile({ refresh: false });
- assert.strictEqual(result.enabled, true);
+ assert.strictEqual(result.mode, 'DAILY');
assert.strictEqual(result.changed, true);
assert.strictEqual(env.stats().triggerCount, 1);
- assert.strictEqual(env.stats().creates, 1);
+ assert.deepStrictEqual(env.stats().createdCadences, ['DAILY']);
})();
-(function testDuplicateTriggersNormalizeToOne() {
- const env = makeContext(3, 3);
+(function testHourlyJobCreatesOneHourlyDispatcher() {
+ const env = makeContext([hourJob('job_hour', 6)], 0, 'NONE');
const result = env.Scheduler.reconcile({ refresh: false });
- assert.strictEqual(result.enabled, true);
+ assert.strictEqual(result.mode, 'HOURLY');
+ assert.strictEqual(result.triggerCount, 1);
+ assert.deepStrictEqual(env.stats().createdCadences, ['HOURLY']);
+})();
+
+(function testMixedSchedulesUseOneHourlyDispatcher() {
+ const env = makeContext([
+ hourJob('job_hour', 12),
+ dayJob('job_daily', 1),
+ dayJob('job_weekly', 7)
+ ], 0, 'NONE');
+ const result = env.Scheduler.reconcile({ refresh: false });
+ assert.strictEqual(result.mode, 'HOURLY');
+ assert.strictEqual(result.automatedJobs, 3);
+ assert.strictEqual(env.stats().triggerCount, 1);
+ assert.deepStrictEqual(env.stats().createdCadences, ['HOURLY']);
+})();
+
+(function testCorrectHourlyTriggerIsNotRecreated() {
+ const env = makeContext([hourJob('job_hour', 2)], 1, 'HOURLY');
+ const result = env.Scheduler.reconcile({ refresh: false });
+ assert.strictEqual(result.changed, false);
+ assert.strictEqual(env.stats().creates, 0);
+ assert.strictEqual(env.stats().deletes, 0);
+})();
+
+(function testDuplicateTriggersNormalizeToOneDesiredCadence() {
+ const env = makeContext([hourJob('job_hour', 8)], 3, 'HOURLY');
+ const result = env.Scheduler.reconcile({ refresh: false });
+ assert.strictEqual(result.mode, 'HOURLY');
assert.strictEqual(env.stats().triggerCount, 1);
assert.strictEqual(env.stats().deletes, 3);
assert.strictEqual(env.stats().creates, 1);
+ assert.deepStrictEqual(env.stats().createdCadences, ['HOURLY']);
+})();
+
+(function testRemovingFinalHourlyJobDowngradesToDaily() {
+ const jobs = [hourJob('job_hour', 6), dayJob('job_day', 7)];
+ const env = makeContext(jobs, 1, 'HOURLY');
+ jobs.splice(0, 1);
+ const result = env.Scheduler.reconcile({ refresh: false });
+ assert.strictEqual(result.mode, 'DAILY');
+ assert.strictEqual(env.stats().deletes, 1);
+ assert.strictEqual(env.stats().creates, 1);
+ assert.deepStrictEqual(env.stats().createdCadences, ['DAILY']);
+})();
+
+(function testDisabledHourlyJobDoesNotUpgradeDispatcher() {
+ const env = makeContext([hourJob('manual_hourly', 1, false), dayJob('job_day', 1)], 1, 'DAILY');
+ const result = env.Scheduler.reconcile({ refresh: false });
+ assert.strictEqual(result.mode, 'DAILY');
+ assert.strictEqual(result.changed, false);
+})();
+
+(function testNoDueHourlyWakeDoesNotRefreshSummary() {
+ const env = makeContext([hourJob('job_hour', 6)], 1, 'HOURLY');
+ env.setRunDueResult({ status: 'No jobs due' });
+ const result = env.Scheduler.runDue();
+ assert.strictEqual(result.status, 'No jobs due');
+ assert.strictEqual(env.stats().summaryRefreshes, 0);
+ assert.strictEqual(env.stats().updateChecks, 1);
+})();
+
+(function testCompletedDueRunRefreshesSummary() {
+ const env = makeContext([hourJob('job_hour', 1)], 1, 'HOURLY');
+ env.setRunDueResult({ status: 'Success' });
+ env.Scheduler.runDue();
+ assert.strictEqual(env.stats().summaryRefreshes, 1);
})();
-(function testOneDailySchedulerArchitectureRemains() {
+(function testOneAdaptiveSchedulerArchitectureRemains() {
assert(schedulerSource.includes('.everyDays(1)'));
+ assert(schedulerSource.includes('.everyHours(1)'));
assert(schedulerSource.includes("var HANDLER = 'spotiSyncScheduler'"));
- assert(!schedulerSource.includes('job.jobId') && !schedulerSource.includes('newTrigger(job'), 'Scheduler must not create per-job triggers.');
+ assert(schedulerSource.includes("HOURLY: 'HOURLY'"));
+ assert(!schedulerSource.includes('newTrigger(job'), 'Scheduler must not create per-job triggers.');
assert(entrypoints.includes('SpotiSync.Scheduler.runDue();'), 'Clock trigger must route through Scheduler telemetry.');
})();
@@ -140,4 +264,4 @@ function makeContext(enabledJobs, initialTriggerCount) {
['Job', 'Source', 'Target', 'Behavior', 'Automation', 'Last sync', 'Status'].forEach((label) => assert(sheetViews.includes(`'${label}'`)));
})();
-console.log('v1.4 scheduler reconciliation and visible-sheet model checks passed.');
+console.log('v1.5 adaptive scheduler reconciliation and visible-sheet model checks passed.');
From 9511e17e2b3da8c60d99e99dfb7e59a670db9a47 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Thu, 20 Aug 2026 05:03:47 +0530
Subject: [PATCH 08/22] Add hourly automation job editor tests
---
scripts/test-job-editor.js | 118 ++++++++++++++++++++++++++++++++-----
1 file changed, 104 insertions(+), 14 deletions(-)
diff --git a/scripts/test-job-editor.js b/scripts/test-job-editor.js
index 2f5f76e..e9d08ba 100644
--- a/scripts/test-job-editor.js
+++ b/scripts/test-job-editor.js
@@ -37,7 +37,7 @@ context.SpotiSync.Auth = {
getRedirectUri() { return 'https://script.google.com/macros/d/test/usercallback'; }
};
context.SpotiSync.Scheduler = {
- getStatus() { return { enabled: false, triggerCount: 0, automatedJobs: 0 }; }
+ getStatus() { return { enabled: false, mode: 'NONE', triggerCount: 0, automatedJobs: 0 }; }
};
context.SpotiSync.UpdateChecker = {
getCachedStatus() { return {}; },
@@ -81,19 +81,50 @@ const sheetViews = fs.readFileSync(path.join(root, 'src', '65_SheetViews.gs'), '
assert.strictEqual(JobEditor._cleanStoredLabel('Open playlist ↗'), '');
})();
-(function testAutomationModelIsOnlyOffDailyOrInterval() {
- assert.strictEqual(JobEditor._automationForJob({ enabled: false, intervalDays: 10 }), 'OFF');
- assert.strictEqual(JobEditor._automationForJob({ enabled: true, intervalDays: 1 }), 'DAILY');
- assert.strictEqual(JobEditor._automationForJob({ enabled: true, intervalDays: 21 }), 'INTERVAL');
+(function testAutomationModelSupportsHoursAndPreservesDays() {
+ assert.strictEqual(JobEditor._automationForJob({ enabled: false, frequencyUnit: 'DAY', frequencyInterval: 10 }), 'OFF');
+ assert.strictEqual(JobEditor._automationForJob({ enabled: true, frequencyUnit: 'HOUR', frequencyInterval: 1 }), 'HOURLY');
+ assert.strictEqual(JobEditor._automationForJob({ enabled: true, frequencyUnit: 'HOUR', frequencyInterval: 6 }), 'HOURS');
+ assert.strictEqual(JobEditor._automationForJob({ enabled: true, frequencyUnit: 'DAY', frequencyInterval: 1 }), 'DAILY');
+ assert.strictEqual(JobEditor._automationForJob({ enabled: true, frequencyUnit: 'DAY', frequencyInterval: 21 }), 'DAYS');
- assert.strictEqual(JobEditor._intervalForPayload({ automation: 'OFF', intervalDays: 21 }), 21);
- assert.strictEqual(JobEditor._intervalForPayload({ automation: 'DAILY', intervalDays: 99 }), 1);
- assert.strictEqual(JobEditor._intervalForPayload({ automation: 'INTERVAL', intervalDays: 21 }), 21);
- assert.throws(() => JobEditor._intervalForPayload({ automation: 'INTERVAL', intervalDays: 0 }), /1 to 3650/);
- assert.throws(() => JobEditor._intervalForPayload({ automation: 'INTERVAL', intervalDays: 3651 }), /1 to 3650/);
+ let frequency = JobEditor._frequencyForPayload({ automation: 'OFF', existingFrequency: 'Every 21 days' });
+ assert.strictEqual(frequency.unit, 'DAY');
+ assert.strictEqual(frequency.interval, 21);
+ assert.strictEqual(frequency.label, 'Every 21 days');
+
+ frequency = JobEditor._frequencyForPayload({ automation: 'HOURLY' });
+ assert.strictEqual(frequency.unit, 'HOUR');
+ assert.strictEqual(frequency.interval, 1);
+ assert.strictEqual(frequency.label, 'Hourly');
+
+ frequency = JobEditor._frequencyForPayload({ automation: 'HOURS', intervalHours: 6 });
+ assert.strictEqual(frequency.unit, 'HOUR');
+ assert.strictEqual(frequency.interval, 6);
+ assert.strictEqual(frequency.label, 'Every 6 hours');
+
+ frequency = JobEditor._frequencyForPayload({ automation: 'DAILY' });
+ assert.strictEqual(frequency.unit, 'DAY');
+ assert.strictEqual(frequency.interval, 1);
+ assert.strictEqual(frequency.label, 'Daily');
+
+ frequency = JobEditor._frequencyForPayload({ automation: 'DAYS', intervalDays: 21 });
+ assert.strictEqual(frequency.unit, 'DAY');
+ assert.strictEqual(frequency.interval, 21);
+ assert.strictEqual(frequency.label, 'Every 21 days');
+
+ // A stale v1.4 sidebar that remained open during an update is still safe.
+ frequency = JobEditor._frequencyForPayload({ automation: 'INTERVAL', intervalDays: 10 });
+ assert.strictEqual(frequency.unit, 'DAY');
+ assert.strictEqual(frequency.interval, 10);
+
+ assert.throws(() => JobEditor._frequencyForPayload({ automation: 'HOURS', intervalHours: 0 }), /1 to 23/);
+ assert.throws(() => JobEditor._frequencyForPayload({ automation: 'HOURS', intervalHours: 24 }), /1 to 23/);
+ assert.throws(() => JobEditor._frequencyForPayload({ automation: 'DAYS', intervalDays: 0 }), /1 to 3650/);
+ assert.throws(() => JobEditor._frequencyForPayload({ automation: 'DAYS', intervalDays: 3651 }), /1 to 3650/);
})();
-(function testExistingJobEditorConfigMapsEnabledToAutomationWithoutChangingIds() {
+(function testExistingDayJobEditorConfigPreservesIdsAndFrequency() {
const job = {
jobId: 'job_keep',
name: 'Archive',
@@ -104,7 +135,11 @@ const sheetViews = fs.readFileSync(path.join(root, 'src', '65_SheetViews.gs'), '
targetPlaylist: 'ABCDEFGHIJKL',
targetLabel: 'Archive ↗',
strategy: 'APPEND',
+ frequencyUnit: 'DAY',
+ frequencyInterval: 21,
+ frequencyLabel: 'Every 21 days',
intervalDays: 21,
+ intervalHours: null,
heartbeatEnabled: false
};
const config = JobEditor._editorConfig(job);
@@ -112,10 +147,51 @@ const sheetViews = fs.readFileSync(path.join(root, 'src', '65_SheetViews.gs'), '
assert.strictEqual(config.sourcePlaylistId, '1234567890AB');
assert.strictEqual(config.targetPlaylistId, 'ABCDEFGHIJKL');
assert.strictEqual(config.automation, 'OFF');
+ assert.strictEqual(config.frequency, 'Every 21 days');
assert.strictEqual(config.intervalDays, 21);
assert.strictEqual(config.heartbeatEnabled, false);
})();
+(function testExistingHourlyJobEditorConfigInitializesCorrectly() {
+ const job = {
+ jobId: 'job_hour',
+ name: 'Fast sync',
+ enabled: true,
+ sourceType: 'LIKED_SONGS',
+ sourcePlaylist: '',
+ sourceLabel: 'Liked Songs',
+ targetPlaylist: 'ABCDEFGHIJKL',
+ targetLabel: 'Fast target',
+ strategy: 'MIRROR',
+ frequencyUnit: 'HOUR',
+ frequencyInterval: 6,
+ frequencyLabel: 'Every 6 hours',
+ intervalDays: null,
+ intervalHours: 6,
+ heartbeatEnabled: true
+ };
+ const config = JobEditor._editorConfig(job);
+ assert.strictEqual(config.automation, 'HOURS');
+ assert.strictEqual(config.frequency, 'Every 6 hours');
+ assert.strictEqual(config.intervalHours, 6);
+})();
+
+(function testCanonicalFrequencyParserOwnsHourlyValidation() {
+ let parsed = SheetStore.parseFrequency('Hourly');
+ assert.strictEqual(parsed.unit, 'HOUR');
+ assert.strictEqual(parsed.interval, 1);
+ parsed = SheetStore.parseFrequency('Every 12 hours');
+ assert.strictEqual(parsed.unit, 'HOUR');
+ assert.strictEqual(parsed.interval, 12);
+ parsed = SheetStore.parseFrequency('Daily');
+ assert.strictEqual(parsed.unit, 'DAY');
+ assert.strictEqual(parsed.interval, 1);
+ parsed = SheetStore.parseFrequency('Every 7 days');
+ assert.strictEqual(parsed.unit, 'DAY');
+ assert.strictEqual(parsed.interval, 7);
+ assert.throws(() => SheetStore.parseFrequency('Every 24 hours'), /Use Daily for 24 hours/);
+})();
+
(function testJobServiceOwnsCatalogButHomeDoesNotFetchIt() {
const homeBlock = editorSource.slice(
editorSource.indexOf('function homeModel()'),
@@ -123,7 +199,7 @@ const sheetViews = fs.readFileSync(path.join(root, 'src', '65_SheetViews.gs'), '
);
const editorBlock = editorSource.slice(
editorSource.indexOf('function editorModel('),
- editorSource.indexOf('function intervalForPayload')
+ editorSource.indexOf('function frequencyForPayload')
);
assert(!homeBlock.includes('getCatalog('), 'Opening app home must not load Spotify playlist catalog.');
assert(editorBlock.includes('getCatalog(false)'), 'Add/Edit may lazy-load the playlist catalog.');
@@ -143,6 +219,8 @@ const sheetViews = fs.readFileSync(path.join(root, 'src', '65_SheetViews.gs'), '
assert(uiSource.includes('YOUR JOBS') || uiSource.includes('Your jobs'));
assert(uiSource.includes('+ Add job'));
assert(uiSource.includes('Automation'));
+ assert(uiSource.includes('intervalHours'));
+ assert(uiSource.includes('intervalDays'));
assert(uiSource.includes('Show Spoti Sync status in playlist description'));
assert(uiSource.includes('Delete job'));
assert(uiSource.includes('Sync now'));
@@ -151,7 +229,7 @@ const sheetViews = fs.readFileSync(path.join(root, 'src', '65_SheetViews.gs'), '
(function testGeneratedSidebarBootIsBrowserSafe() {
const html = context.SpotiSync.Ui._appHtml({
- version: '1.4.1',
+ version: '1.5.0',
connected: true,
clientIdHint: '',
redirectUri: '',
@@ -178,6 +256,18 @@ const sheetViews = fs.readFileSync(path.join(root, 'src', '65_SheetViews.gs'), '
assert(clientScript.includes('renderHome(STATE);'), 'Initial home render must still occur from the embedded model.');
})();
+(function testAutomationOptionsComeFromServerModel() {
+ const editorBlock = editorSource.slice(
+ editorSource.indexOf('function editorModel('),
+ editorSource.indexOf('function frequencyForPayload')
+ );
+ ['Off', 'Hourly', 'Every N hours', 'Daily', 'Every N days'].forEach((label) => {
+ assert(editorBlock.includes(`label: '${label}'`), `Missing automation option: ${label}`);
+ });
+ assert(uiSource.includes('(EDITOR.automationOptions||[]).map'), 'Sidebar should render canonical server automation options.');
+ assert(!uiSource.includes('value=\\"INTERVAL\\"'), 'Legacy INTERVAL must not be exposed as a new UI option.');
+})();
+
(function testJobEditorNoLongerContainsSecondSidebarImplementation() {
assert(!editorSource.includes('showSidebar('));
assert(!editorSource.includes('function editorHtml('));
@@ -203,4 +293,4 @@ const sheetViews = fs.readFileSync(path.join(root, 'src', '65_SheetViews.gs'), '
assert(!uiSource.includes('spotiSyncSearchPlaylists'));
})();
-console.log('v1.4 single-surface app, sidebar boot, and Job service tests passed.');
+console.log('v1.5 single-surface app, hourly automation, sidebar boot, and Job service tests passed.');
From 55e5977df7e552433c6a2d45f5cec19f17152803 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Thu, 20 Aug 2026 05:04:10 +0530
Subject: [PATCH 09/22] Protect manual sync and hourly no-op semantics
---
scripts/test-v14.js | 17 +++++++++++++----
1 file changed, 13 insertions(+), 4 deletions(-)
diff --git a/scripts/test-v14.js b/scripts/test-v14.js
index dcfac3f..d0a95e8 100644
--- a/scripts/test-v14.js
+++ b/scripts/test-v14.js
@@ -11,6 +11,8 @@ const activity = [];
let updatedSuccess = 0;
let summaryRefreshes = 0;
let heartbeatCalls = 0;
+let runSummaries = 0;
+let sourceReads = 0;
const disabledJob = {
rowNumber: 2,
@@ -21,6 +23,8 @@ const disabledJob = {
sourcePlaylist: '',
targetPlaylist: '1234567890AB',
strategy: 'MIRROR',
+ frequencyUnit: 'DAY',
+ frequencyInterval: 10,
intervalDays: 10,
heartbeatEnabled: true
};
@@ -46,11 +50,11 @@ context.SpotiSync.SheetStore = {
updateJobError() {},
updateConfigurationError() {},
appendActivity(entry) { activity.push(entry); },
- setRunSummary() {},
+ setRunSummary() { runSummaries += 1; },
refreshSummary() { summaryRefreshes += 1; }
};
context.SpotiSync.Sources = {
- getForJob() { return { tracks: [{ writeUri: 'spotify:track:A' }] }; },
+ getForJob() { sourceReads += 1; return { tracks: [{ writeUri: 'spotify:track:A' }] }; },
getTargetPlaylist() { return { tracks: [] }; },
invalidatePlaylist() {}
};
@@ -77,12 +81,17 @@ load('70_SyncEngine.gs');
assert.strictEqual(activity.length, 1);
assert.strictEqual(summaryRefreshes, 1);
assert.strictEqual(heartbeatCalls, 1);
+ assert.strictEqual(sourceReads, 1);
+ assert.strictEqual(runSummaries, 1);
})();
-(function testDueSchedulerPathDoesNotRunAutomationOffJob() {
+(function testDueSchedulerPathDoesNotRunAutomationOffJobOrWriteNoOpRunSummary() {
const result = context.SpotiSync.SyncEngine.runDue();
assert.strictEqual(result.jobs.length, 0);
assert.strictEqual(result.status, 'No jobs due');
+ assert.strictEqual(sourceReads, 1, 'No-due scheduler tick must not read Spotify sources.');
+ assert.strictEqual(activity.length, 1, 'No-due scheduler tick must not add Activity noise.');
+ assert.strictEqual(runSummaries, 1, 'No-due scheduler tick must not replace the last real run summary.');
})();
(function testTargetedManualRunDoesNotTemporarilyEnableJob() {
@@ -107,4 +116,4 @@ const views = fs.readFileSync(path.join(root, 'src', '65_SheetViews.gs'), 'utf8'
assert(!views.includes('refreshSchedule'));
})();
-console.log('v1.4 manual-run, heartbeat, and one-summary-refresh tests passed.');
+console.log('v1.5 manual-run, heartbeat, no-op scheduler, and one-summary-refresh tests passed.');
From b7431b284e8967575ddb95db7d1d83b3932d1419 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Thu, 20 Aug 2026 05:04:48 +0530
Subject: [PATCH 10/22] Add focused v1.5 frequency and due-time tests
---
scripts/test-v15.js | 114 ++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 114 insertions(+)
create mode 100644 scripts/test-v15.js
diff --git a/scripts/test-v15.js b/scripts/test-v15.js
new file mode 100644
index 0000000..bf8505a
--- /dev/null
+++ b/scripts/test-v15.js
@@ -0,0 +1,114 @@
+#!/usr/bin/env node
+'use strict';
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const root = path.resolve(__dirname, '..');
+const context = vm.createContext({
+ console, Date, Object, Array, String, Number, Boolean, Math, JSON, RegExp, Error,
+ encodeURIComponent, decodeURIComponent,
+ SpreadsheetApp: {
+ getActiveSpreadsheet() {
+ return { getSpreadsheetTimeZone() { return 'UTC'; } };
+ }
+ },
+ Utilities: {
+ getUuid() { return '12345678-1234-1234-1234-123456789abc'; },
+ formatDate(date, timezone, pattern) {
+ if (pattern === 'yyyy-MM-dd') { return date.toISOString().slice(0, 10); }
+ return date.toISOString();
+ }
+ }
+});
+
+function load(filename) {
+ vm.runInContext(fs.readFileSync(path.join(root, 'src', filename), 'utf8'), context, { filename });
+}
+
+load('00_Core.gs');
+load('60_SheetStore.gs');
+
+const { Core, SheetStore } = context.SpotiSync;
+
+function jobRow(frequency, lastSuccess = '') {
+ return [
+ true, 'Schedule test', 'Liked Songs', 'Target', 'Exact Mirror', frequency, '', '',
+ 'job_schedule', '', '1234567890AB', '', lastSuccess, '', 0, 0, '', true
+ ];
+}
+
+(function testDaySchedulesKeepExistingRepresentation() {
+ let result = SheetStore._parseJobRows([jobRow('Daily')], 2);
+ assert.strictEqual(result.errors.length, 0);
+ assert.strictEqual(result.jobs[0].frequencyUnit, 'DAY');
+ assert.strictEqual(result.jobs[0].frequencyInterval, 1);
+ assert.strictEqual(result.jobs[0].intervalDays, 1);
+ assert.strictEqual(result.jobs[0].intervalHours, null);
+
+ result = SheetStore._parseJobRows([jobRow('Every 7 days')], 2);
+ assert.strictEqual(result.errors.length, 0);
+ assert.strictEqual(result.jobs[0].frequencyUnit, 'DAY');
+ assert.strictEqual(result.jobs[0].frequencyInterval, 7);
+ assert.strictEqual(result.jobs[0].intervalDays, 7);
+})();
+
+(function testHourlySchedulesUseSameFrequencyColumn() {
+ let result = SheetStore._parseJobRows([jobRow('Hourly')], 2);
+ assert.strictEqual(result.errors.length, 0);
+ assert.strictEqual(result.jobs[0].frequencyUnit, 'HOUR');
+ assert.strictEqual(result.jobs[0].frequencyInterval, 1);
+ assert.strictEqual(result.jobs[0].intervalHours, 1);
+ assert.strictEqual(result.jobs[0].intervalDays, null);
+ assert.strictEqual(SheetStore.getAutomationLabel(result.jobs[0]), 'Hourly');
+
+ result = SheetStore._parseJobRows([jobRow('Every 6 hours')], 2);
+ assert.strictEqual(result.errors.length, 0);
+ assert.strictEqual(result.jobs[0].frequencyUnit, 'HOUR');
+ assert.strictEqual(result.jobs[0].frequencyInterval, 6);
+ assert.strictEqual(result.jobs[0].intervalHours, 6);
+ assert.strictEqual(SheetStore.getAutomationLabel(result.jobs[0]), 'Every 6 hours');
+})();
+
+(function testHourlyDueMathUsesElapsedHours() {
+ const last = new Date('2026-08-20T00:00:00Z');
+ assert.strictEqual(Core.isDueByElapsedHours(last, 6, new Date('2026-08-20T05:59:59Z')), false);
+ assert.strictEqual(Core.isDueByElapsedHours(last, 6, new Date('2026-08-20T06:00:00Z')), true);
+
+ const job = SheetStore._parseJobRows([jobRow('Every 6 hours', last)], 2).jobs[0];
+ assert.strictEqual(SheetStore.isJobDue(job, new Date('2026-08-20T05:59:59Z')), false);
+ assert.strictEqual(SheetStore.isJobDue(job, new Date('2026-08-20T06:00:00Z')), true);
+})();
+
+(function testDayDueMathStillUsesCalendarDays() {
+ const job = SheetStore._parseJobRows([
+ jobRow('Every 7 days', new Date('2026-08-13T23:59:00Z'))
+ ], 2).jobs[0];
+ assert.strictEqual(SheetStore.isJobDue(job, new Date('2026-08-20T00:01:00Z')), true);
+})();
+
+(function testFrequencyBoundsAndCanonicalLabels() {
+ assert.strictEqual(SheetStore.parseFrequency('Every 23 hours').label, 'Every 23 hours');
+ assert.strictEqual(SheetStore.parseFrequency('Every 3650 days').label, 'Every 3650 days');
+ assert.throws(() => SheetStore.parseFrequency('Every 0 hours'), /1 to 23/);
+ assert.throws(() => SheetStore.parseFrequency('Every 24 hours'), /Use Daily for 24 hours/);
+ assert.throws(() => SheetStore.parseFrequency('Every 0 days'), /1 to 3650/);
+ assert.throws(() => SheetStore.parseFrequency('Every 3651 days'), /1 to 3650/);
+})();
+
+(function testHotPathSafetyGuards() {
+ const scheduler = fs.readFileSync(path.join(root, 'src', '80_Scheduler.gs'), 'utf8');
+ const syncEngine = fs.readFileSync(path.join(root, 'src', '70_SyncEngine.gs'), 'utf8');
+ const sheetStore = fs.readFileSync(path.join(root, 'src', '60_SheetStore.gs'), 'utf8');
+
+ assert(scheduler.includes('.everyHours(1)'), 'Sub-daily jobs must use one hourly dispatcher.');
+ assert(!scheduler.includes('newTrigger(job'), 'No per-job triggers may be created.');
+ assert(!scheduler.includes('clearFormats()'));
+ assert(!syncEngine.includes('clearFormats()'));
+ assert(!sheetStore.includes('clearFormats()'));
+ assert(!syncEngine.includes('SheetStore.initialize('), 'Normal sync must not invoke migration/repair.');
+})();
+
+console.log('v1.5 frequency, due-time, and hot-path safety checks passed.');
From f0995cc61e7f46fb5c144f86fde398fa823d4931 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Thu, 20 Aug 2026 05:04:58 +0530
Subject: [PATCH 11/22] Run v1.5 sub-daily automation regressions in CI
---
.github/workflows/ci.yml | 2 ++
1 file changed, 2 insertions(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9e0e175..4338c5b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -25,6 +25,8 @@ jobs:
run: node scripts/test.js
- name: Run v1.4 single-surface tests
run: node scripts/test-v14.js
+ - name: Run v1.5 sub-daily automation tests
+ run: node scripts/test-v15.js
- name: Run sheet and scheduler checks
run: node scripts/test-scheduler.js
- name: Run migration and storage checks
From 1249572ea15f0b5773a100ac59020b79841957b9 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Thu, 20 Aug 2026 05:05:34 +0530
Subject: [PATCH 12/22] Document v1.5 hourly automation
---
README.md | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index a110720..89194b0 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@ Spoti Sync has one normal control surface: **Spoti Sync → Open Spoti Sync**. T
- Use **Liked Songs** or a Spotify playlist as the source.
- Sync into an existing playlist or create a new target playlist.
- Choose **Exact Mirror** or **Append Only** behavior.
-- Set **Automation: Off, Daily, or Every N days**.
+- Set **Automation: Off, Hourly, Every N hours, Daily, or Every N days**.
- Run any job manually with **Sync now**.
- Optionally keep the target playlist description updated with Spoti Sync status.
@@ -43,11 +43,13 @@ No web-app deployment, local server, Node.js installation, or `clasp` setup is r
| **Source** | Liked Songs or Spotify playlist |
| **Target** | Existing playlist or create a new playlist |
| **Behavior** | Exact Mirror or Append Only |
-| **Automation** | Off, Daily, or Every N days |
+| **Automation** | Off, Hourly, Every N hours, Daily, or Every N days |
**Exact Mirror** keeps the managed target membership aligned with the source.
**Append Only** adds missing source tracks and never removes existing target tracks.
+Spoti Sync still uses one background scheduler. If any job uses an hour-based interval, jobs are checked hourly; Spotify is contacted only for jobs that are actually due.
+
## 🔐 Privacy
Spoti Sync runs in your own Google Apps Script environment. Spotify access and refresh tokens are stored in Apps Script User Properties, not on a hosted Spoti Sync backend or GitHub Pages.
@@ -71,6 +73,7 @@ Node.js 22+ is used only for local build and test tooling.
node scripts/build.js
node scripts/test.js
node scripts/test-v14.js
+node scripts/test-v15.js
node scripts/test-scheduler.js
node scripts/test-sheet-repair.js
node scripts/test-job-editor.js
From 093c8d0e7cb9c99de5cc8bd68c894889ff521594 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Thu, 20 Aug 2026 05:06:00 +0530
Subject: [PATCH 13/22] Document adaptive v1.5 scheduler architecture
---
ARCHITECTURE.md | 41 ++++++++++++++++++++++++-----------------
1 file changed, 24 insertions(+), 17 deletions(-)
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 4a32ad8..e13de5c 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -27,7 +27,7 @@ User's Google Sheet
+-- job persistence
+-- sync engine
+-- optional playlist heartbeat
- +-- one reconciled daily scheduler
+ +-- one reconciled scheduler
+-- single sidebar application
+-- read-only status + Activity views
|
@@ -58,14 +58,16 @@ User-facing:
Internal:
-- **Jobs** — hidden durable job records, including stable Job IDs, playlist IDs, behavior, automation interval, heartbeat preference and per-job telemetry.
-- **Schedule** — legacy v1.3 sheet preserved/hidden on upgrade; normal v1.4 runtime does not render it.
+- **Jobs** — hidden durable job records, including stable Job IDs, playlist IDs, behavior, automation frequency, heartbeat preference and per-job telemetry.
+- **Schedule** — legacy v1.3 sheet preserved/hidden on upgrade; normal v1.5 runtime does not render it.
+
+The existing `Frequency` cell is the canonical serialized schedule. It stores values such as `Hourly`, `Every 6 hours`, `Daily`, or `Every 7 days`; no additional scheduling column is required for v1.5.
### Document Properties
-- scheduler telemetry
+- scheduler telemetry and current scheduler mode (`NONE`, `DAILY`, or `HOURLY`)
- update-check cache/status
-- latest run summary
+- latest real run summary
- playlist display-name cache
- one tiny heartbeat phrase index per stable Job ID
@@ -111,12 +113,14 @@ Runtime identity uses hidden Spotify playlist IDs, not human-readable display la
Automation maps onto the existing storage model:
```text
-Off → enabled=false
-Daily → enabled=true, intervalDays=1
-Every N days → enabled=true, intervalDays=N
+Off → enabled=false, stored Frequency retained
+Hourly → enabled=true, unit=HOUR, interval=1
+Every N hours → enabled=true, unit=HOUR, interval=N (1–23)
+Daily → enabled=true, unit=DAY, interval=1
+Every N days → enabled=true, unit=DAY, interval=N (1–3650)
```
-The canonical server-side Frequency parser remains responsible for validating the supported 1–3650 day interval.
+The canonical server-side Frequency parser remains responsible for validation. Day-based schedules retain the existing calendar-day semantics; hour-based schedules use elapsed hours since the last successful run.
## Runtime model
@@ -125,13 +129,16 @@ The canonical server-side Frequency parser remains responsible for validating th
`Scheduler.reconcile()` enforces the invariant:
```text
-0 automated jobs → 0 Spoti Sync triggers
-1+ automated jobs → exactly 1 Spoti Sync daily trigger
+0 automated jobs → 0 Spoti Sync triggers
+only Daily / Every N days jobs → exactly 1 DAILY Spoti Sync trigger
+any Hourly / Every N hours job present → exactly 1 HOURLY Spoti Sync trigger
```
-An already-correct single trigger is retained. Missing or duplicate Spoti Sync triggers are normalized. There are no per-job triggers.
+There is never a daily and hourly Spoti Sync scheduler trigger at the same time, and there are no per-job triggers.
+
+The scheduler persists the selected dispatcher mode in Document Properties because Apps Script project-trigger objects identify the handler but do not expose enough recurrence metadata for Spoti Sync to reliably infer whether an existing clock trigger is hourly or daily. A single legacy v1.4 trigger with no stored mode is treated as DAILY, so an upgrade with only day-based jobs does not recreate a correct trigger.
-The daily trigger invokes `spotiSyncScheduler()`, which runs enabled jobs only when their configured interval is due.
+The hourly dispatcher is deliberately cheap. It loads local job state, evaluates due jobs, and exits when none are due. A no-due check does not call Spotify, append an Activity row, overwrite the last real run summary, or repaint the status sheet. Scheduler health telemetry may still be updated.
### Manual runs
@@ -186,9 +193,9 @@ Normal runtime does not format or add validation to hidden Jobs, and does not re
## Migration
-v1.4 upgrades the v1.3.8 Jobs schema by appending `Heartbeat Enabled` and defaults existing configured jobs to `true`.
+v1.5 does not add a Jobs schema column. Existing `Daily` and `Every N days` Frequency strings continue to parse exactly as day schedules and retain their existing calendar-day behavior.
-Migration preserves stable Job IDs, playlist IDs, names, behavior, frequencies, automation state and telemetry. It remains explicit/bounded: no whole-sheet `clearFormats()` and no OAuth/property reset.
+The v1.4 migration protections remain in place: stable Job IDs, playlist IDs, names, behavior, frequencies, automation state, heartbeat state and telemetry are preserved. Migration remains explicit/bounded: no whole-sheet `clearFormats()` and no OAuth/property reset.
The old Dashboard is renamed to Spoti Sync when safe. A conflicting unrelated user sheet named `Spoti Sync` must never be cleared; a safe status fallback is used instead.
@@ -212,9 +219,9 @@ Strategies remain pure planning logic.
## Safety and resilience
-- Script lock prevents overlapping playlist writes.
+- Script lock prevents overlapping playlist writes, including manual and scheduled runs.
- Spotify `401` can trigger token refresh/retry.
-- Spotify `429` honors practical Retry-After values.
+- Spotify `429` honors practical `Retry-After` values and stops rather than sleeping past the Apps Script execution budget when the requested wait is too long.
- transient `5xx` responses use bounded retries.
- one failed job does not stop later jobs.
- heartbeat failure does not replay playlist mutations.
From b7f6e1589f08b1fbe385372866257be4106fad0a Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Thu, 20 Aug 2026 05:06:25 +0530
Subject: [PATCH 14/22] Show hourly automation on the public product page
---
docs/index.html | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/index.html b/docs/index.html
index faeb50c..7af7014 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -70,9 +70,9 @@
Only the choices that matter.
SourceLiked Songs or Spotify playlistTargetExisting playlist or create newBehaviorExact Mirror or Append Only
- AutomationOff · Daily Every N days
+ AutomationOff · Hourly · Every N hours Daily · Every N days
-
Optional per job: keep the target playlist description updated with the latest successful sync time.
+
Hour-based jobs are checked hourly, but Spotify is contacted only when a job is actually due. Optional per job: keep the target playlist description updated with the latest successful sync time.
From 2f308329f5f15c6f47b06259ba0c17cf8dbb2f32 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Thu, 20 Aug 2026 05:06:35 +0530
Subject: [PATCH 15/22] Publish v1.5.0 release metadata
---
docs/version.json | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/docs/version.json b/docs/version.json
index 3c756cb..73f83f2 100644
--- a/docs/version.json
+++ b/docs/version.json
@@ -1,13 +1,13 @@
{
"schema": 1,
- "version": "1.4.1",
+ "version": "1.5.0",
"channel": "stable",
- "released_at": "2026-08-16",
+ "released_at": "2026-08-20",
"installer_url": "https://sid.is-a.dev/Spoti-sync/#update",
"changelog_url": "https://github.com/11sid11/Spoti-sync/blob/main/CHANGELOG.md",
"notes": [
- "Fixes the blank v1.4 sidebar caused by a browser-global top identifier collision in generated client JavaScript.",
- "Adds a visible loading state and compact runtime boot-error fallback so sidebar failures are no longer silent.",
- "Adds regression coverage that parses the generated production sidebar script and guards against redeclaring the browser top global."
+ "Adds Hourly and Every N hours automation while keeping the existing Off, Daily, and Every N days options.",
+ "Keeps a single adaptive Apps Script scheduler: daily for day-only jobs, hourly when any sub-daily job exists, with Spotify contacted only for due jobs.",
+ "Preserves existing jobs, playlist IDs, OAuth state, day-based scheduling semantics, manual Sync now behavior, and heartbeat settings."
]
}
From aab5cbd83dba3148f54cdde2c9c6c0536ea6354d Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Thu, 20 Aug 2026 05:06:48 +0530
Subject: [PATCH 16/22] Keep docs freshness checks aligned with v1.5 automation
---
scripts/test-docs.js | 1 +
1 file changed, 1 insertion(+)
diff --git a/scripts/test-docs.js b/scripts/test-docs.js
index abe05e8..471a8e0 100644
--- a/scripts/test-docs.js
+++ b/scripts/test-docs.js
@@ -81,6 +81,7 @@ const requiredCurrentFlow = [
'Spoti Sync → Open Spoti Sync',
'Exact Mirror',
'Append Only',
+ 'Every N hours',
'Every N days'
];
From 84b93fcfd88eb5c51faf0a8fefb5384d09154f92 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Thu, 20 Aug 2026 05:08:50 +0530
Subject: [PATCH 17/22] Add v1.5.0 hourly automation changelog
---
CHANGELOG.md | 29 +++++++++++++++++++++++++++++
1 file changed, 29 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aa69a09..25fd05c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,35 @@
All notable changes to Spoti Sync are documented here.
+## 1.5.0 — 2026-08-20
+
+### Added
+
+- Added **Hourly** and **Every N hours** automation alongside the existing Off, Daily, and Every N days options.
+- Hour-based intervals support 1–23 hours; 24 hours remains represented canonically as **Daily**.
+
+### Automation
+
+- Kept the single-trigger architecture. `Scheduler.reconcile()` now selects one of three states: no trigger when no jobs are automated, one daily trigger for day-only jobs, or one hourly dispatcher when any hour-based job exists.
+- Mixed schedules still use only one trigger. The hourly dispatcher checks local job state first and calls Spotify only for jobs that are actually due.
+- Existing single v1.4 daily triggers are retained for day-only installations instead of being recreated unnecessarily; scheduler mode is persisted in Document Properties so later reconciliations can distinguish daily and hourly cadence safely.
+
+### Compatibility and performance
+
+- Existing `Daily` and `Every N days` values keep their calendar-day semantics and require no Jobs schema migration.
+- Hour-based schedules use elapsed hours since the last successful run.
+- A no-due hourly scheduler wake does not fetch Spotify data, append Activity noise, overwrite the last real run summary, or repaint the status sheet.
+- Preserved manual Sync now for Automation Off jobs, script locking, Exact Mirror / Append Only behavior, playlist heartbeat semantics, OAuth state, playlist IDs and stable Job IDs.
+
+### Tests
+
+- Added regression coverage for hourly parsing/bounds, elapsed-hour eligibility, mixed daily/hourly reconciliation, legacy daily-trigger retention, hourly-to-daily downgrade, duplicate-trigger normalization, no-due execution cost and existing manual/day-based behavior.
+- Kept generated-sidebar boot protections, migration guards, no-`clearFormats()` protections, playlist-catalog laziness and installer-manifest checks in CI.
+
+### Upgrade note
+
+- Install the 1.5.0 bundle in the same Apps Script project, save, reload the Sheet, then choose **Spoti Sync → Open Spoti Sync**. Existing Spotify connection, Client ID, jobs, playlist IDs, heartbeat preferences and automation are preserved; no repair or manual trigger recreation is required.
+
## 1.4.1 — 2026-08-16
### Fixed
From bb204ae2e346836aa304ffe3dce22fbe1d81dfdf Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Thu, 20 Aug 2026 05:11:16 +0530
Subject: [PATCH 18/22] Update storage regression for canonical hour/day
frequencies
---
scripts/test-sheet-repair.js | 30 ++++++++++++++++++++++++++----
1 file changed, 26 insertions(+), 4 deletions(-)
diff --git a/scripts/test-sheet-repair.js b/scripts/test-sheet-repair.js
index e76a26e..14c2877 100644
--- a/scripts/test-sheet-repair.js
+++ b/scripts/test-sheet-repair.js
@@ -88,6 +88,7 @@ const sheetViews = fs.readFileSync(path.join(root, 'src', '65_SheetViews.gs'), '
assert.strictEqual(parsed.jobs.length, 1);
assert.strictEqual(parsed.jobs[0].enabled, false);
assert.strictEqual(parsed.jobs[0].strategy, 'APPEND');
+ assert.strictEqual(parsed.jobs[0].frequencyUnit, 'DAY');
assert.strictEqual(parsed.jobs[0].intervalDays, 21);
assert.strictEqual(parsed.jobs[0].heartbeatEnabled, false);
assert.strictEqual(SheetStore.getAutomationLabel(parsed.jobs[0]), 'Off');
@@ -100,13 +101,34 @@ const sheetViews = fs.readFileSync(path.join(root, 'src', '65_SheetViews.gs'), '
'Every 14 days', 'Every 30 days', 'Every 60 days', 'Every 90 days']
);
assert.deepStrictEqual(Array.from(SheetStore.behaviorOptions()), ['Exact Mirror', 'Append Only']);
- assert.strictEqual(SheetStore._parseFrequency('Daily'), 1);
- assert.strictEqual(SheetStore._parseFrequency('Every 21 days'), 21);
+
+ let parsed = SheetStore._parseFrequency('Daily');
+ assert.strictEqual(parsed.unit, 'DAY');
+ assert.strictEqual(parsed.interval, 1);
+ assert.strictEqual(parsed.label, 'Daily');
+
+ parsed = SheetStore._parseFrequency('Every 21 days');
+ assert.strictEqual(parsed.unit, 'DAY');
+ assert.strictEqual(parsed.interval, 21);
+ assert.strictEqual(parsed.label, 'Every 21 days');
+
+ parsed = SheetStore._parseFrequency('Hourly');
+ assert.strictEqual(parsed.unit, 'HOUR');
+ assert.strictEqual(parsed.interval, 1);
+ assert.strictEqual(parsed.label, 'Hourly');
+
+ parsed = SheetStore._parseFrequency('Every 6 hours');
+ assert.strictEqual(parsed.unit, 'HOUR');
+ assert.strictEqual(parsed.interval, 6);
+ assert.strictEqual(parsed.label, 'Every 6 hours');
+
+ assert.throws(() => SheetStore._parseFrequency('Every 0 hours'), /1 to 23/);
+ assert.throws(() => SheetStore._parseFrequency('Every 24 hours'), /Use Daily for 24 hours/);
assert.throws(() => SheetStore._parseFrequency('Every 0 days'), /1 to 3650/);
assert.throws(() => SheetStore._parseFrequency('Every 3651 days'), /1 to 3650/);
})();
-(function testJobsAreStorageOnlyInV14() {
+(function testJobsAreStorageOnlyInV15() {
assert(!sheetViews.includes('requireCheckbox()'), 'SheetViews must not build Jobs configuration checkboxes.');
assert(!sheetViews.includes('requireValueInList('), 'SheetViews must not build Jobs configuration dropdowns.');
assert(!sheetViews.includes('refreshJobsStatus'), 'Jobs presentation refresh must be removed.');
@@ -129,4 +151,4 @@ const sheetViews = fs.readFileSync(path.join(root, 'src', '65_SheetViews.gs'), '
assert(replacement.indexOf('.setValues(values);') < replacement.lastIndexOf('.clearContent();'));
})();
-console.log('v1.4 storage migration and status-only Sheet ownership checks passed.');
+console.log('v1.5 storage migration, frequency ownership, and status-only Sheet checks passed.');
From 43afd412abd7d89706d3c3f14cf3242191bd426e Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Thu, 20 Aug 2026 05:12:37 +0530
Subject: [PATCH 19/22] Advance update-checker future-release fixture for v1.5
---
scripts/test-update-checker.js | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/scripts/test-update-checker.js b/scripts/test-update-checker.js
index 5fabdb5..2cb82ec 100644
--- a/scripts/test-update-checker.js
+++ b/scripts/test-update-checker.js
@@ -12,7 +12,7 @@ let fetchCount = 0;
let responseStatus = 200;
let responseBody = {
schema: 1,
- version: '1.4.2',
+ version: '1.5.1',
channel: 'stable',
released_at: '2026-09-01',
installer_url: 'https://example.com/update',
@@ -71,7 +71,7 @@ const { UpdateChecker, VERSION } = context.SpotiSync;
const status = UpdateChecker.check({ force: true });
assert.strictEqual(fetchCount, 1);
assert.strictEqual(status.currentVersion, VERSION);
- assert.strictEqual(status.latestVersion, '1.4.2');
+ assert.strictEqual(status.latestVersion, '1.5.1');
assert.strictEqual(status.updateAvailable, true);
assert.strictEqual(status.checkStatus, 'Update available');
assert.strictEqual(status.installerUrl, 'https://example.com/update');
@@ -80,15 +80,15 @@ const { UpdateChecker, VERSION } = context.SpotiSync;
(function testNormalCheckUsesDailyCache() {
const status = UpdateChecker.check({ force: false });
assert.strictEqual(fetchCount, 1, 'Fresh cached status must avoid another GitHub request.');
- assert.strictEqual(status.latestVersion, '1.4.2');
+ assert.strictEqual(status.latestVersion, '1.5.1');
})();
(function testCachedAvailabilityRecomputesAfterCodeUpgrade() {
const installedVersion = context.SpotiSync.VERSION;
- context.SpotiSync.VERSION = '1.4.2';
+ context.SpotiSync.VERSION = '1.5.1';
const status = UpdateChecker.getCachedStatus();
- assert.strictEqual(status.currentVersion, '1.4.2');
- assert.strictEqual(status.latestVersion, '1.4.2');
+ assert.strictEqual(status.currentVersion, '1.5.1');
+ assert.strictEqual(status.latestVersion, '1.5.1');
assert.strictEqual(status.updateAvailable, false);
assert.strictEqual(status.checkStatus, 'Up to date');
context.SpotiSync.VERSION = installedVersion;
@@ -126,7 +126,7 @@ const { UpdateChecker, VERSION } = context.SpotiSync;
responseStatus = 200;
responseBody = {
...responseBody,
- version: '1.4.2'
+ version: '1.5.1'
};
documentStatus.UPDATE_LAST_CHECK_AT = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
const status = UpdateChecker.check({ force: false });
From 37137abcc77de1a22155d1a1f88d82f82a7ed9dc Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Thu, 20 Aug 2026 05:14:35 +0530
Subject: [PATCH 20/22] Preserve daily execution window under hourly dispatcher
---
src/70_SyncEngine.gs | 34 ++++++++++++++++++++++++++++++----
1 file changed, 30 insertions(+), 4 deletions(-)
diff --git a/src/70_SyncEngine.gs b/src/70_SyncEngine.gs
index 5208c23..c243f4a 100644
--- a/src/70_SyncEngine.gs
+++ b/src/70_SyncEngine.gs
@@ -94,13 +94,33 @@ var SpotiSync = SpotiSync || {};
return errors.filter(function (item) { return item.enabled; });
}
+ function isDueInDispatcher(job, options, now) {
+ var opts = options || {};
+ var current = now || new Date();
+
+ if (!ns.SheetStore.isJobDue(job, current)) {
+ return false;
+ }
+
+ if (opts.schedulerMode === 'HOURLY' &&
+ job.frequencyUnit === ns.Constants.FREQUENCY_UNITS.DAY) {
+ return Number(Utilities.formatDate(
+ current,
+ ns.SheetStore.getSpreadsheetTimezone(),
+ 'H'
+ )) === ns.Constants.DEFAULT_SCHEDULER_HOUR;
+ }
+
+ return true;
+ }
+
function matchingJobs(jobs, options, now) {
var opts = options || {};
if (opts.jobId) {
return jobs.filter(function (job) { return job.jobId === opts.jobId; });
}
return jobs.filter(function (job) {
- return job.enabled && (!opts.dueOnly || ns.SheetStore.isJobDue(job, now));
+ return job.enabled && (!opts.dueOnly || isDueInDispatcher(job, opts, now));
});
}
@@ -263,9 +283,14 @@ var SpotiSync = SpotiSync || {};
});
},
- runDue: function () {
+ runDue: function (options) {
+ var settings = options || {};
return ns.SyncEngine.withWriteLock(function () {
- return runInternal({ dueOnly: true, write: true });
+ return runInternal({
+ dueOnly: true,
+ write: true,
+ schedulerMode: settings.schedulerMode || ''
+ });
});
},
@@ -283,6 +308,7 @@ var SpotiSync = SpotiSync || {};
_executeJob: executeJob,
_planJob: planJob,
+ _isDueInDispatcher: isDueInDispatcher,
_runInternal: runInternal
};
-})(SpotiSync);
+})(SpotiSync);
\ No newline at end of file
From 7194bebc1cc042b9789de5c514c02660b3521b11 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Thu, 20 Aug 2026 05:14:58 +0530
Subject: [PATCH 21/22] Pass scheduler mode into due-job dispatch
---
src/80_Scheduler.gs | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/80_Scheduler.gs b/src/80_Scheduler.gs
index 2bd0ecd..2dcf475 100644
--- a/src/80_Scheduler.gs
+++ b/src/80_Scheduler.gs
@@ -183,7 +183,8 @@ var SpotiSync = SpotiSync || {};
runDue: function () {
try {
- var result = ns.SyncEngine.runDue();
+ var mode = storedMode(schedulerTriggers());
+ var result = ns.SyncEngine.runDue({ schedulerMode: mode });
recordSchedulerCheck(result.status || 'Success', null);
checkForUpdatesBestEffort();
if (result.status !== 'No jobs due') {
From 3455d1d64fef41028f32ced9652094c582acff09 Mon Sep 17 00:00:00 2001
From: Siddharth Sahoo <67169975+11sid11@users.noreply.github.com>
Date: Thu, 20 Aug 2026 05:15:55 +0530
Subject: [PATCH 22/22] Test mixed hourly and daily scheduling compatibility
---
scripts/test-v15.js | 31 +++++++++++++++++++++++++++++--
1 file changed, 29 insertions(+), 2 deletions(-)
diff --git a/scripts/test-v15.js b/scripts/test-v15.js
index bf8505a..1f32526 100644
--- a/scripts/test-v15.js
+++ b/scripts/test-v15.js
@@ -19,6 +19,7 @@ const context = vm.createContext({
getUuid() { return '12345678-1234-1234-1234-123456789abc'; },
formatDate(date, timezone, pattern) {
if (pattern === 'yyyy-MM-dd') { return date.toISOString().slice(0, 10); }
+ if (pattern === 'H') { return String(date.getUTCHours()); }
return date.toISOString();
}
}
@@ -30,8 +31,9 @@ function load(filename) {
load('00_Core.gs');
load('60_SheetStore.gs');
+load('70_SyncEngine.gs');
-const { Core, SheetStore } = context.SpotiSync;
+const { Core, SheetStore, SyncEngine } = context.SpotiSync;
function jobRow(frequency, lastSuccess = '') {
return [
@@ -89,6 +91,30 @@ function jobRow(frequency, lastSuccess = '') {
assert.strictEqual(SheetStore.isJobDue(job, new Date('2026-08-20T00:01:00Z')), true);
})();
+(function testHourlyDispatcherPreservesExistingDailyWindow() {
+ const dayJob = SheetStore._parseJobRows([
+ jobRow('Every 7 days', new Date('2026-08-13T23:59:00Z'))
+ ], 2).jobs[0];
+ const hourJob = SheetStore._parseJobRows([
+ jobRow('Every 6 hours', new Date('2026-08-19T20:00:00Z'))
+ ], 2).jobs[0];
+
+ assert.strictEqual(
+ SyncEngine._isDueInDispatcher(dayJob, { schedulerMode: 'HOURLY' }, new Date('2026-08-20T02:30:00Z')),
+ false,
+ 'A mixed hourly dispatcher must not pull existing day jobs forward before the legacy daily window.'
+ );
+ assert.strictEqual(
+ SyncEngine._isDueInDispatcher(dayJob, { schedulerMode: 'HOURLY' }, new Date('2026-08-20T03:30:00Z')),
+ true
+ );
+ assert.strictEqual(
+ SyncEngine._isDueInDispatcher(hourJob, { schedulerMode: 'HOURLY' }, new Date('2026-08-20T02:30:00Z')),
+ true,
+ 'Hour-based jobs are governed by elapsed hours, not the daily window.'
+ );
+})();
+
(function testFrequencyBoundsAndCanonicalLabels() {
assert.strictEqual(SheetStore.parseFrequency('Every 23 hours').label, 'Every 23 hours');
assert.strictEqual(SheetStore.parseFrequency('Every 3650 days').label, 'Every 3650 days');
@@ -104,6 +130,7 @@ function jobRow(frequency, lastSuccess = '') {
const sheetStore = fs.readFileSync(path.join(root, 'src', '60_SheetStore.gs'), 'utf8');
assert(scheduler.includes('.everyHours(1)'), 'Sub-daily jobs must use one hourly dispatcher.');
+ assert(scheduler.includes('schedulerMode: mode'), 'Scheduler must tell SyncEngine which dispatcher cadence invoked it.');
assert(!scheduler.includes('newTrigger(job'), 'No per-job triggers may be created.');
assert(!scheduler.includes('clearFormats()'));
assert(!syncEngine.includes('clearFormats()'));
@@ -111,4 +138,4 @@ function jobRow(frequency, lastSuccess = '') {
assert(!syncEngine.includes('SheetStore.initialize('), 'Normal sync must not invoke migration/repair.');
})();
-console.log('v1.5 frequency, due-time, and hot-path safety checks passed.');
+console.log('v1.5 frequency, due-time, mixed-schedule compatibility, and hot-path safety checks passed.');