Skip to content

Commit c19fa1f

Browse files
authored
feat(schedules): dedicated Schedules page with multi-schedule support (#26)
1 parent 1f89805 commit c19fa1f

File tree

17 files changed

+1560
-251
lines changed

17 files changed

+1560
-251
lines changed

src/main/index.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const execFileAsync = promisify(execFile)
77
import { IPC } from '../shared/channels'
88
import { registerCleanerIpc } from './ipc'
99
import { getSettings } from './services/settings-store'
10-
import { startScheduler, stopScheduler, getNextScanTime, notifyScheduledScanComplete } from './services/scheduler'
10+
import { startScheduler, stopScheduler, getNextScanTime, notifyScheduledScanComplete, completeScheduleRun } from './services/scheduler'
1111
import { initAutoUpdater } from './services/auto-updater'
1212
import { cloudAgent } from './services/cloud-agent'
1313
import { runCli } from './cli'
@@ -339,8 +339,8 @@ app.whenReady().then(() => {
339339
console.error('Failed to configure auto-launch:', err)
340340
})
341341

342-
// Create tray if minimize-to-tray is enabled or scheduled scans are on
343-
if (settings.minimizeToTray || settings.schedule.enabled) {
342+
// Create tray if minimize-to-tray is enabled or any schedule is active
343+
if (settings.minimizeToTray || settings.schedules.some((s) => s.enabled)) {
344344
createTray()
345345
}
346346

@@ -365,7 +365,7 @@ app.whenReady().then(() => {
365365
ipcMain.on(IPC.SETTINGS_APPLY_TRAY, (_event, enabled: boolean) => {
366366
if (enabled) {
367367
createTray()
368-
} else if (!getSettings().schedule.enabled) {
368+
} else if (!getSettings().schedules.some((s) => s.enabled)) {
369369
destroyTray()
370370
}
371371
})
@@ -382,6 +382,14 @@ app.whenReady().then(() => {
382382
notifyScheduledScanComplete(totalSize, itemCount)
383383
})
384384

385+
// Handle multi-schedule run completion
386+
const VALID_RUN_STATUSES = new Set(['success', 'partial', 'failed', 'never'])
387+
ipcMain.on(IPC.SCHEDULE_RUN_COMPLETE, (_event, scheduleId: unknown, status: unknown) => {
388+
if (typeof scheduleId !== 'string' || typeof status !== 'string') return
389+
if (!VALID_RUN_STATUSES.has(status)) return
390+
completeScheduleRun(scheduleId, status as 'success' | 'partial' | 'failed' | 'never')
391+
})
392+
385393
app.on('activate', () => {
386394
if (mainWindow && !mainWindow.isDestroyed()) {
387395
// Window exists but may be hidden (minimize-to-tray) — restore it
@@ -395,8 +403,8 @@ app.whenReady().then(() => {
395403

396404
app.on('window-all-closed', () => {
397405
const settings = getSettings()
398-
// Don't quit if minimize-to-tray or scheduled scans are enabled
399-
if (settings.minimizeToTray || settings.schedule.enabled) {
406+
// Don't quit if minimize-to-tray or any schedule is enabled
407+
if (settings.minimizeToTray || settings.schedules.some((s) => s.enabled)) {
400408
// Stay alive in tray
401409
return
402410
}

src/main/services/ipc-validation.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,52 @@ describe('validateSettingsPartial', () => {
102102
it('accepts empty object', () => {
103103
expect(validateSettingsPartial({})).toEqual({})
104104
})
105+
106+
it('accepts valid schedules array', () => {
107+
const input = {
108+
schedules: [{
109+
id: 'abc-123',
110+
name: 'Weekly Clean',
111+
enabled: true,
112+
frequency: 'weekly',
113+
day: 1,
114+
hour: 9,
115+
tasks: ['cleaner:system', 'cleaner:browsers'],
116+
autoApply: false,
117+
lastRunAt: null,
118+
lastRunStatus: 'never',
119+
createdAt: '2025-01-01T00:00:00Z'
120+
}]
121+
}
122+
expect(validateSettingsPartial(input)).toEqual(input)
123+
})
124+
125+
it('rejects schedules with invalid task types', () => {
126+
expect(validateSettingsPartial({
127+
schedules: [{
128+
id: 'x', name: 'X', enabled: true, frequency: 'daily', day: 0, hour: 9,
129+
tasks: ['badtask'], autoApply: false, lastRunAt: null, lastRunStatus: 'never', createdAt: '2025-01-01T00:00:00Z'
130+
}]
131+
})).toBeNull()
132+
})
133+
134+
it('rejects non-array schedules', () => {
135+
expect(validateSettingsPartial({ schedules: 'not-array' })).toBeNull()
136+
})
137+
138+
it('rejects too many schedules', () => {
139+
const schedules = Array.from({ length: 11 }, (_, i) => ({
140+
id: `id-${i}`, name: `S${i}`, enabled: true, frequency: 'daily', day: 0, hour: 9,
141+
tasks: ['cleaner:system'], autoApply: false, lastRunAt: null, lastRunStatus: 'never', createdAt: '2025-01-01T00:00:00Z'
142+
}))
143+
expect(validateSettingsPartial({ schedules })).toBeNull()
144+
})
145+
146+
it('rejects schedule entry with missing fields', () => {
147+
expect(validateSettingsPartial({
148+
schedules: [{ id: 'x', name: 'X' }]
149+
})).toBeNull()
150+
})
105151
})
106152

107153
describe('validateHistoryEntry', () => {

src/main/services/ipc-validation.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export function validateSettingsPartial(input: unknown): Record<string, unknown>
1414
const allowedTopKeys = new Set([
1515
'minimizeToTray', 'showNotificationOnComplete', 'showThreatNotifications',
1616
'runAtStartup', 'autoUpdate', 'autoRestart', 'updateCheckIntervalHours',
17-
'cleaner', 'exclusions', 'schedule', 'cloud'
17+
'cleaner', 'exclusions', 'schedule', 'schedules', 'cloud'
1818
])
1919

2020
for (const key of Object.keys(obj)) {
@@ -57,6 +57,34 @@ export function validateSettingsPartial(input: unknown): Record<string, unknown>
5757
if ('frequency' in s && !['daily', 'weekly', 'monthly'].includes(s.frequency as string)) return null
5858
}
5959

60+
// Validate schedules array if present
61+
if ('schedules' in obj && obj.schedules !== undefined) {
62+
if (!Array.isArray(obj.schedules)) return null
63+
if (obj.schedules.length > 10) return null
64+
const validTaskTypes = new Set([
65+
'cleaner:system', 'cleaner:browsers', 'cleaner:apps', 'cleaner:gaming',
66+
'cleaner:recycleBin', 'cleaner:databases', 'registry', 'drivers', 'software-update'
67+
])
68+
const validFrequencies = new Set(['daily', 'weekly', 'monthly'])
69+
const validStatuses = new Set(['success', 'partial', 'failed', 'never'])
70+
for (const entry of obj.schedules) {
71+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return null
72+
const e = entry as Record<string, unknown>
73+
if (typeof e.id !== 'string' || e.id.length > 100) return null
74+
if (typeof e.name !== 'string' || e.name.length > 100) return null
75+
if (typeof e.enabled !== 'boolean') return null
76+
if (!validFrequencies.has(e.frequency as string)) return null
77+
if (typeof e.day !== 'number' || e.day < 0 || e.day > 31) return null
78+
if (typeof e.hour !== 'number' || e.hour < 0 || e.hour > 23) return null
79+
if (!Array.isArray(e.tasks) || e.tasks.length > 20) return null
80+
if (!e.tasks.every((t: unknown) => typeof t === 'string' && validTaskTypes.has(t as string))) return null
81+
if (typeof e.autoApply !== 'boolean') return null
82+
if (e.lastRunAt !== null && (typeof e.lastRunAt !== 'string' || e.lastRunAt.length > 50)) return null
83+
if (!validStatuses.has(e.lastRunStatus as string)) return null
84+
if (typeof e.createdAt !== 'string' || e.createdAt.length > 50) return null
85+
}
86+
}
87+
6088
// Validate cleaner has expected shape if present
6189
if ('cleaner' in obj && obj.cleaner !== undefined) {
6290
const c = obj.cleaner as Record<string, unknown>

src/main/services/scheduler.test.ts

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ vi.mock('electron', () => ({
55
BrowserWindow: class {},
66
Notification: { isSupported: () => false },
77
}))
8-
vi.mock('./settings-store', () => ({ getSettings: () => ({}) }))
8+
vi.mock('./settings-store', () => ({ getSettings: () => ({}), setSettings: () => {} }))
99
vi.mock('./history-store', () => ({ getHistory: () => [] }))
1010
vi.mock('./logger', () => ({ logInfo: () => {}, logError: () => {} }))
1111

12-
import { getNextScanTime, isSameDay } from './scheduler'
13-
import type { KuduSettings } from '../../shared/types'
12+
import { getNextScanTime, getNextRunTime, isSameDay } from './scheduler'
13+
import type { KuduSettings, ScheduleEntry } from '../../shared/types'
1414

1515
function makeSettings(
1616
overrides: Partial<KuduSettings['schedule']> & { enabled?: boolean } = {}
@@ -36,6 +36,7 @@ function makeSettings(
3636
day: overrides.day ?? 1,
3737
hour: overrides.hour ?? 9,
3838
},
39+
schedules: [],
3940
cloud: {
4041
apiKey: '',
4142
serverUrl: '',
@@ -177,4 +178,75 @@ describe('getNextScanTime', () => {
177178
const result = getNextScanTime(settings)!
178179
expect(result.getTime()).toBeGreaterThan(new Date('2025-06-15T12:00:00').getTime())
179180
})
181+
182+
it('returns soonest schedule when multiple schedules exist', () => {
183+
vi.setSystemTime(new Date('2025-06-15T07:00:00')) // Sunday
184+
const settings = makeSettings({ enabled: false })
185+
settings.schedules = [
186+
makeEntry({ frequency: 'daily', hour: 20 }), // today at 20:00
187+
makeEntry({ frequency: 'daily', hour: 10 }), // today at 10:00 (soonest)
188+
makeEntry({ frequency: 'weekly', day: 3, hour: 9 }), // Wed at 9:00
189+
]
190+
const result = getNextScanTime(settings)!
191+
expect(result.getHours()).toBe(10)
192+
expect(result.getDate()).toBe(15) // today
193+
})
194+
})
195+
196+
// ─── getNextRunTime (per-entry) ───────────────────────────
197+
198+
function makeEntry(overrides: Partial<ScheduleEntry> = {}): ScheduleEntry {
199+
return {
200+
id: 'test-' + Math.random(),
201+
name: 'Test Schedule',
202+
enabled: true,
203+
frequency: 'daily',
204+
day: 1,
205+
hour: 9,
206+
tasks: ['cleaner:system'],
207+
autoApply: false,
208+
lastRunAt: null,
209+
lastRunStatus: 'never',
210+
createdAt: new Date().toISOString(),
211+
...overrides,
212+
}
213+
}
214+
215+
describe('getNextRunTime', () => {
216+
beforeEach(() => {
217+
vi.useFakeTimers()
218+
})
219+
220+
afterEach(() => {
221+
vi.useRealTimers()
222+
})
223+
224+
it('returns null when entry is disabled', () => {
225+
expect(getNextRunTime(makeEntry({ enabled: false }))).toBeNull()
226+
})
227+
228+
it('returns today for daily schedule if hour has not passed', () => {
229+
vi.setSystemTime(new Date('2025-06-15T07:00:00'))
230+
const result = getNextRunTime(makeEntry({ frequency: 'daily', hour: 9 }))!
231+
expect(result.getDate()).toBe(15)
232+
expect(result.getHours()).toBe(9)
233+
})
234+
235+
it('returns tomorrow for daily schedule if hour has passed', () => {
236+
vi.setSystemTime(new Date('2025-06-15T10:00:00'))
237+
const result = getNextRunTime(makeEntry({ frequency: 'daily', hour: 9 }))!
238+
expect(result.getDate()).toBe(16)
239+
})
240+
241+
it('returns correct day of week for weekly schedule', () => {
242+
vi.setSystemTime(new Date('2025-06-15T07:00:00')) // Sunday
243+
const result = getNextRunTime(makeEntry({ frequency: 'weekly', day: 3, hour: 9 }))!
244+
expect(result.getDay()).toBe(3) // Wednesday
245+
})
246+
247+
it('clamps day for monthly schedule in short months', () => {
248+
vi.setSystemTime(new Date('2025-02-01T07:00:00'))
249+
const result = getNextRunTime(makeEntry({ frequency: 'monthly', day: 31, hour: 9 }))!
250+
expect(result.getDate()).toBeLessThanOrEqual(28)
251+
})
180252
})

0 commit comments

Comments
 (0)