diff --git a/web/src/lib/maintenance/policy-schedule.ts b/web/src/lib/maintenance/policy-schedule.ts
new file mode 100644
index 0000000..8d8a636
--- /dev/null
+++ b/web/src/lib/maintenance/policy-schedule.ts
@@ -0,0 +1,181 @@
+/** Режимы расписания (5-field cron, UTC) — совместимы с robfig/cron в scheduler. */
+export type ScheduleMode = 'minutes' | 'hours' | 'daily' | 'weekly' | 'custom';
+
+export type ScheduleEditor = {
+ mode: ScheduleMode;
+ /** Интервал в минутах (режим minutes). */
+ intervalMinutes: string;
+ /** Интервал в часах (режим hours). */
+ intervalHours: string;
+ minute: string;
+ hour: string;
+ /** 0 = воскресенье … 6 = суббота */
+ weekday: string;
+ customCron: string;
+};
+
+export const scheduleModeOptions: { value: ScheduleMode; label: string }[] = [
+ { value: 'minutes', label: 'Каждые N минут' },
+ { value: 'hours', label: 'Каждые N часов' },
+ { value: 'daily', label: 'Ежедневно в указанное время' },
+ { value: 'weekly', label: 'Еженедельно в указанный день' },
+ { value: 'custom', label: 'Cron вручную (расширенный)' }
+];
+
+export const weekdayOptions = [
+ { value: '0', label: 'Воскресенье' },
+ { value: '1', label: 'Понедельник' },
+ { value: '2', label: 'Вторник' },
+ { value: '3', label: 'Среда' },
+ { value: '4', label: 'Четверг' },
+ { value: '5', label: 'Пятница' },
+ { value: '6', label: 'Суббота' }
+] as const;
+
+export function defaultScheduleEditor(cron = '0 3 * * *'): ScheduleEditor {
+ return cronToEditor(cron);
+}
+
+function splitCron(cron: string): [string, string, string, string, string] | null {
+ const parts = cron.trim().split(/\s+/);
+ if (parts.length !== 5) return null;
+ return [parts[0], parts[1], parts[2], parts[3], parts[4]];
+}
+
+function clampInt(raw: string, min: number, max: number): number {
+ const n = Math.floor(Number(String(raw).trim()));
+ if (!Number.isFinite(n)) return min;
+ return Math.min(max, Math.max(min, n));
+}
+
+function pad2(n: number): string {
+ return String(n).padStart(2, '0');
+}
+
+/** Пытается разобрать cron в редактор; неизвестные выражения → custom. */
+export function cronToEditor(cron: string): ScheduleEditor {
+ const base: ScheduleEditor = {
+ mode: 'custom',
+ intervalMinutes: '30',
+ intervalHours: '2',
+ minute: '0',
+ hour: '3',
+ weekday: '0',
+ customCron: cron.trim() || '0 3 * * *'
+ };
+
+ const parts = splitCron(cron);
+ if (!parts) return base;
+
+ const [min, hour, dom, month, dow] = parts;
+
+ const minEvery = min.match(/^\*\/(\d+)$/);
+ if (minEvery && hour === '*' && dom === '*' && month === '*' && dow === '*') {
+ return { ...base, mode: 'minutes', intervalMinutes: minEvery[1], customCron: cron.trim() };
+ }
+
+ const hourEvery = hour.match(/^\*\/(\d+)$/);
+ if (hourEvery && !min.includes('*') && dom === '*' && month === '*' && dow === '*') {
+ return {
+ ...base,
+ mode: 'hours',
+ intervalHours: hourEvery[1],
+ minute: min,
+ customCron: cron.trim()
+ };
+ }
+
+ if (!min.includes('*') && !hour.includes('*') && dom === '*' && month === '*' && dow === '*') {
+ return { ...base, mode: 'daily', minute: min, hour, customCron: cron.trim() };
+ }
+
+ if (!min.includes('*') && !hour.includes('*') && dom === '*' && month === '*' && dow !== '*') {
+ return { ...base, mode: 'weekly', minute: min, hour, weekday: dow, customCron: cron.trim() };
+ }
+
+ return base;
+}
+
+/** Собирает 5-field cron из редактора. */
+export function editorToCron(editor: ScheduleEditor): string {
+ switch (editor.mode) {
+ case 'minutes': {
+ const n = clampInt(editor.intervalMinutes, 1, 59);
+ return `*/${n} * * * *`;
+ }
+ case 'hours': {
+ const n = clampInt(editor.intervalHours, 1, 23);
+ const m = clampInt(editor.minute, 0, 59);
+ return `${m} */${n} * * *`;
+ }
+ case 'daily': {
+ const m = clampInt(editor.minute, 0, 59);
+ const h = clampInt(editor.hour, 0, 23);
+ return `${m} ${h} * * *`;
+ }
+ case 'weekly': {
+ const m = clampInt(editor.minute, 0, 59);
+ const h = clampInt(editor.hour, 0, 23);
+ const d = clampInt(editor.weekday, 0, 6);
+ return `${m} ${h} * * ${d}`;
+ }
+ case 'custom':
+ return editor.customCron.trim() || '0 3 * * *';
+ }
+}
+
+/** Человекочитаемое описание расписания для таблицы и подсказок. */
+export function describeCron(cron: string): string {
+ const ed = cronToEditor(cron);
+ switch (ed.mode) {
+ case 'minutes': {
+ const n = clampInt(ed.intervalMinutes, 1, 59);
+ return `Каждые ${n} ${minutesLabel(n)} (UTC)`;
+ }
+ case 'hours': {
+ const n = clampInt(ed.intervalHours, 1, 23);
+ const m = clampInt(ed.minute, 0, 59);
+ return `Каждые ${n} ${hoursLabel(n)}, в :${pad2(m)} (UTC)`;
+ }
+ case 'daily': {
+ const h = clampInt(ed.hour, 0, 23);
+ const m = clampInt(ed.minute, 0, 59);
+ return `Ежедневно в ${pad2(h)}:${pad2(m)} UTC`;
+ }
+ case 'weekly': {
+ const wd =
+ weekdayOptions.find((w) => w.value === String(clampInt(ed.weekday, 0, 6)))?.label ??
+ ed.weekday;
+ const h = clampInt(ed.hour, 0, 23);
+ const m = clampInt(ed.minute, 0, 59);
+ return `Каждую ${wd.toLowerCase()} в ${pad2(h)}:${pad2(m)} UTC`;
+ }
+ case 'custom':
+ return `Cron: ${cron.trim()}`;
+ }
+}
+
+/** Синхронизирует form.schedule из редактора. */
+export function applyScheduleEditor(editor: ScheduleEditor): {
+ editor: ScheduleEditor;
+ cron: string;
+} {
+ if (editor.mode === 'custom') {
+ const cron = editor.customCron.trim() || '0 3 * * *';
+ return { editor: { ...editor, customCron: cron }, cron };
+ }
+ const cron = editorToCron(editor);
+ return { editor: { ...editor, customCron: cron }, cron };
+}
+
+function minutesLabel(n: number): string {
+ if (n % 10 === 1 && n % 100 !== 11) return 'минуту';
+ if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) return 'минуты';
+ return 'минут';
+}
+
+function hoursLabel(n: number): string {
+ if (n % 10 === 1 && n % 100 !== 11) return 'час';
+ if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) return 'часа';
+ return 'часов';
+}