From 5ea2abff850aaaf87a6b937d78628ad37282abac Mon Sep 17 00:00:00 2001 From: ARUNAVO RAY Date: Thu, 19 Mar 2026 00:58:10 +0530 Subject: [PATCH] feat: custom sync start time and frequency scheduling (#241) * feat: add custom sync start time scheduling * Updated UI * docs: add updated issue 240 UI screenshot * fix: improve schedule UI with client-side next run calc and timezone handling - Compute next scheduled run client-side via useMemo to avoid permanent "Calculating..." state when server hasn't set nextRun yet - Default to browser timezone when enabling syncing (not UTC) - Show actual saved timezone in badge, use it consistently in all handlers - Match time input background to select trigger in dark mode - Add clock icon to time picker with hidden native indicator --- design/giteamirror.pen | 804 +++++++++++++++++++ docs/images/issue-240-automation-ui-v2.png | Bin 0 -> 22340 bytes docs/images/issue-240-automation-ui.png | Bin 0 -> 30318 bytes src/components/config/AutomationSettings.tsx | 216 +++-- src/lib/scheduler-service.ts | 158 ++-- src/lib/utils/config-defaults.ts | 21 +- src/lib/utils/config-mapper.test.ts | 36 + src/lib/utils/config-mapper.ts | 53 +- src/lib/utils/schedule-utils.test.ts | 65 ++ src/lib/utils/schedule-utils.ts | 420 ++++++++++ src/pages/api/job/schedule-sync-repo.ts | 18 +- src/types/config.ts | 8 +- 12 files changed, 1627 insertions(+), 172 deletions(-) create mode 100644 design/giteamirror.pen create mode 100644 docs/images/issue-240-automation-ui-v2.png create mode 100644 docs/images/issue-240-automation-ui.png create mode 100644 src/lib/utils/config-mapper.test.ts create mode 100644 src/lib/utils/schedule-utils.test.ts create mode 100644 src/lib/utils/schedule-utils.ts diff --git a/design/giteamirror.pen b/design/giteamirror.pen new file mode 100644 index 0000000..85a01d7 --- /dev/null +++ b/design/giteamirror.pen @@ -0,0 +1,804 @@ +{ + "version": "2.9", + "children": [ + { + "type": "frame", + "id": "eIiDx", + "x": 0, + "y": 0, + "name": "Scheduling Settings - Redesign", + "width": 1080, + "fill": "#09090B", + "cornerRadius": 16, + "gap": 24, + "padding": 32, + "children": [ + { + "type": "frame", + "id": "7r0Wv", + "name": "Automatic Syncing Card", + "clip": true, + "width": "fill_container", + "fill": "#18181B", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#27272A" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "gyCPG", + "name": "Header", + "width": "fill_container", + "gap": 12, + "padding": [ + 20, + 24 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "OunzZ", + "name": "headerIcon", + "width": 20, + "height": 20, + "iconFontName": "refresh-cw", + "iconFontFamily": "lucide", + "fill": "#A1A1AA" + }, + { + "type": "text", + "id": "fMdlX", + "name": "headerTitle", + "fill": "#FAFAFA", + "content": "Automatic Syncing", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "600" + } + ] + }, + { + "type": "rectangle", + "id": "4cX02", + "name": "divider1", + "fill": "#27272A", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "Kiezh", + "name": "Toggle Section", + "width": "fill_container", + "gap": 14, + "padding": [ + 20, + 24 + ], + "children": [ + { + "type": "frame", + "id": "QCPzN", + "name": "Checkbox", + "width": 20, + "height": 20, + "fill": "#6366F1", + "cornerRadius": 4, + "layout": "none", + "children": [ + { + "type": "icon_font", + "id": "4FTax", + "x": 3, + "y": 3, + "name": "checkIcon", + "width": 14, + "height": 14, + "iconFontName": "check", + "iconFontFamily": "lucide", + "fill": "#FFFFFF" + } + ] + }, + { + "type": "frame", + "id": "FTzs6", + "name": "toggleText", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "1nJKC", + "name": "toggleLabel", + "fill": "#FAFAFA", + "content": "Enable automatic repository syncing", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "text", + "id": "r1O5t", + "name": "toggleDesc", + "fill": "#71717A", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Periodically sync GitHub changes to Gitea", + "fontFamily": "Inter", + "fontSize": 13 + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "nvQ6R", + "name": "divider2", + "fill": "#27272A", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "FOoBn", + "name": "Schedule Builder", + "width": "fill_container", + "layout": "vertical", + "gap": 20, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "IqHEu", + "name": "schedHeader", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "RnVoM", + "name": "schedTitle", + "fill": "#A1A1AA", + "content": "SCHEDULE", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600", + "letterSpacing": 1 + }, + { + "type": "frame", + "id": "aVtIZ", + "name": "tzBadge", + "fill": "#27272A", + "cornerRadius": 20, + "gap": 6, + "padding": [ + 4, + 10 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "iXpYV", + "name": "tzIcon", + "width": 12, + "height": 12, + "iconFontName": "globe", + "iconFontFamily": "lucide", + "fill": "#71717A" + }, + { + "type": "text", + "id": "WjPMl", + "name": "tzText", + "fill": "#A1A1AA", + "content": "UTC", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "P02fk", + "name": "formRow", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "kcYK5", + "name": "Frequency", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "vMvsN", + "name": "label2", + "fill": "#A1A1AA", + "content": "Frequency", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "3prth", + "name": "select2", + "width": "fill_container", + "height": 40, + "fill": "#27272A", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#3F3F46" + }, + "padding": [ + 0, + 12 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "ANY36", + "name": "sel2Text", + "fill": "#FAFAFA", + "content": "Daily", + "fontFamily": "Inter", + "fontSize": 13 + }, + { + "type": "icon_font", + "id": "GUWfd", + "name": "sel2Icon", + "width": 16, + "height": 16, + "iconFontName": "chevron-down", + "iconFontFamily": "lucide", + "fill": "#71717A" + } + ] + } + ] + }, + { + "type": "frame", + "id": "xphp0", + "name": "Start Time", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "l6VkR", + "name": "label3", + "fill": "#A1A1AA", + "content": "Start Time", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "frame", + "id": "lWBDi", + "name": "timeInput", + "width": "fill_container", + "height": 40, + "fill": "#27272A", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#3F3F46" + }, + "padding": [ + 0, + 12 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "fbuMi", + "name": "timeText", + "fill": "#FAFAFA", + "content": "10:00 PM", + "fontFamily": "Inter", + "fontSize": 13 + }, + { + "type": "icon_font", + "id": "5xKW7", + "name": "timeIcon", + "width": 16, + "height": 16, + "iconFontName": "clock-4", + "iconFontFamily": "lucide", + "fill": "#71717A" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "BtYt7", + "name": "divider3", + "fill": "#27272A", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "520Kb", + "name": "Status Bar", + "width": "fill_container", + "padding": [ + 16, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "J8JzX", + "name": "lastSync", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "MS5VM", + "name": "lastIcon", + "width": 14, + "height": 14, + "iconFontName": "history", + "iconFontFamily": "lucide", + "fill": "#52525B" + }, + { + "type": "text", + "id": "8KJHY", + "name": "lastLabel", + "fill": "#52525B", + "content": "Last sync", + "fontFamily": "Inter", + "fontSize": 12 + }, + { + "type": "text", + "id": "Fz116", + "name": "lastValue", + "fill": "#A1A1AA", + "content": "Never", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "ZbRFN", + "name": "nextSync", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "wIKSk", + "name": "nextIcon", + "width": 14, + "height": 14, + "iconFontName": "calendar", + "iconFontFamily": "lucide", + "fill": "#52525B" + }, + { + "type": "text", + "id": "ejqSP", + "name": "nextLabel", + "fill": "#52525B", + "content": "Next sync", + "fontFamily": "Inter", + "fontSize": 12 + }, + { + "type": "text", + "id": "M4oJ7", + "name": "nextValue", + "fill": "#6366F1", + "content": "Calculating...", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "7PK7H", + "name": "Database Maintenance Card", + "clip": true, + "width": "fill_container", + "height": "fill_container", + "fill": "#18181B", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#27272A" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "FAaon", + "name": "Header", + "width": "fill_container", + "gap": 12, + "padding": [ + 20, + 24 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "64CaE", + "name": "rHeaderIcon", + "width": 20, + "height": 20, + "iconFontName": "database", + "iconFontFamily": "lucide", + "fill": "#A1A1AA" + }, + { + "type": "text", + "id": "rvZlC", + "name": "rHeaderTitle", + "fill": "#FAFAFA", + "content": "Database Maintenance", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "600" + } + ] + }, + { + "type": "rectangle", + "id": "nsM0M", + "name": "rDivider1", + "fill": "#27272A", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "8zhPi", + "name": "Toggle Section", + "width": "fill_container", + "gap": 14, + "padding": [ + 20, + 24 + ], + "children": [ + { + "type": "frame", + "id": "eQbZk", + "name": "Checkbox", + "width": 20, + "height": 20, + "fill": "#6366F1", + "cornerRadius": 4, + "layout": "none", + "children": [ + { + "type": "icon_font", + "id": "t6PbY", + "x": 3, + "y": 3, + "name": "rCheckIcon", + "width": 14, + "height": 14, + "iconFontName": "check", + "iconFontFamily": "lucide", + "fill": "#FFFFFF" + } + ] + }, + { + "type": "frame", + "id": "lpBPI", + "name": "rToggleText", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "Kuy1S", + "name": "rToggleLabel", + "fill": "#FAFAFA", + "content": "Enable automatic database cleanup", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "text", + "id": "OviVY", + "name": "rToggleDesc", + "fill": "#71717A", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Remove old activity logs to optimize storage", + "fontFamily": "Inter", + "fontSize": 13 + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "1og3D", + "name": "rDivider2", + "fill": "#27272A", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "J7576", + "name": "Retention Section", + "width": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": 24, + "children": [ + { + "type": "frame", + "id": "JZA6R", + "name": "retLabelRow", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Diiak", + "name": "retLabel", + "fill": "#FAFAFA", + "content": "Data retention period", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "1qqCe", + "name": "retInfoIcon", + "width": 14, + "height": 14, + "iconFontName": "info", + "iconFontFamily": "lucide", + "fill": "#52525B" + } + ] + }, + { + "type": "frame", + "id": "kfUjs", + "name": "retRow", + "width": "fill_container", + "gap": 16, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "9bhls", + "name": "retSelect", + "width": 180, + "height": 40, + "fill": "#27272A", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#3F3F46" + }, + "padding": [ + 0, + 12 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "3NOod", + "name": "retSelText", + "fill": "#FAFAFA", + "content": "1 month", + "fontFamily": "Inter", + "fontSize": 13 + }, + { + "type": "icon_font", + "id": "8QBA8", + "name": "retSelIcon", + "width": 16, + "height": 16, + "iconFontName": "chevron-down", + "iconFontFamily": "lucide", + "fill": "#71717A" + } + ] + }, + { + "type": "text", + "id": "GA6ye", + "name": "retHelper", + "fill": "#52525B", + "content": "Cleanup runs every 2 days", + "fontFamily": "Inter", + "fontSize": 12 + } + ] + } + ] + }, + { + "type": "rectangle", + "id": "WfXVB", + "name": "rDivider3", + "fill": "#27272A", + "width": "fill_container", + "height": 1 + }, + { + "type": "frame", + "id": "WpXnI", + "name": "Cleanup Status", + "width": "fill_container", + "layout": "vertical", + "gap": 12, + "padding": [ + 16, + 24 + ], + "children": [ + { + "type": "frame", + "id": "fbpm5", + "name": "lastCleanup", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "DdLix", + "name": "lastCleanupLeft", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "FN2cj", + "name": "lastCleanIcon", + "width": 14, + "height": 14, + "iconFontName": "history", + "iconFontFamily": "lucide", + "fill": "#52525B" + }, + { + "type": "text", + "id": "JjmMa", + "name": "lastCleanLabel", + "fill": "#52525B", + "content": "Last cleanup", + "fontFamily": "Inter", + "fontSize": 12 + } + ] + }, + { + "type": "text", + "id": "l1Kph", + "name": "lastCleanValue", + "fill": "#A1A1AA", + "content": "Never", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "AWHY8", + "name": "nextCleanup", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "sj0qN", + "name": "nextCleanupLeft", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "V6RTK", + "name": "nextCleanIcon", + "width": 14, + "height": 14, + "iconFontName": "calendar", + "iconFontFamily": "lucide", + "fill": "#52525B" + }, + { + "type": "text", + "id": "wf0b4", + "name": "nextCleanLabel", + "fill": "#52525B", + "content": "Next cleanup", + "fontFamily": "Inter", + "fontSize": 12 + } + ] + }, + { + "type": "text", + "id": "YWZGH", + "name": "nextCleanValue", + "fill": "#6366F1", + "content": "March 20, 2026 at 12:19 AM", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/docs/images/issue-240-automation-ui-v2.png b/docs/images/issue-240-automation-ui-v2.png new file mode 100644 index 0000000000000000000000000000000000000000..4b611a53cc71572314124f25ee479fbd9ca80860 GIT binary patch literal 22340 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 literal 0 HcmV?d00001 diff --git a/src/components/config/AutomationSettings.tsx b/src/components/config/AutomationSettings.tsx index 547627f..49afb1f 100644 --- a/src/components/config/AutomationSettings.tsx +++ b/src/components/config/AutomationSettings.tsx @@ -1,7 +1,8 @@ -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; import { Select, SelectContent, @@ -19,6 +20,7 @@ import { Zap, Info, Archive, + Globe, } from "lucide-react"; import { Tooltip, @@ -28,6 +30,10 @@ import { } from "@/components/ui/tooltip"; import type { ScheduleConfig, DatabaseCleanupConfig } from "@/types/config"; import { formatDate } from "@/lib/utils"; +import { + buildClockCronExpression, + getNextCronOccurrence, +} from "@/lib/utils/schedule-utils"; interface AutomationSettingsProps { scheduleConfig: ScheduleConfig; @@ -38,15 +44,13 @@ interface AutomationSettingsProps { isAutoSavingCleanup?: boolean; } -const scheduleIntervals = [ - { label: "Every hour", value: 3600 }, - { label: "Every 2 hours", value: 7200 }, - { label: "Every 4 hours", value: 14400 }, - { label: "Every 8 hours", value: 28800 }, - { label: "Every 12 hours", value: 43200 }, - { label: "Daily", value: 86400 }, - { label: "Every 2 days", value: 172800 }, - { label: "Weekly", value: 604800 }, +const clockFrequencies = [ + { label: "Every hour", value: 1 }, + { label: "Every 2 hours", value: 2 }, + { label: "Every 4 hours", value: 4 }, + { label: "Every 8 hours", value: 8 }, + { label: "Every 12 hours", value: 12 }, + { label: "Daily", value: 24 }, ]; const retentionPeriods = [ @@ -85,6 +89,27 @@ export function AutomationSettings({ isAutoSavingSchedule, isAutoSavingCleanup, }: AutomationSettingsProps) { + const browserTimezone = + typeof Intl !== "undefined" + ? Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC" + : "UTC"; + + // Use saved timezone, but treat "UTC" as unset for users who never chose it + const effectiveTimezone = scheduleConfig.timezone || browserTimezone; + + const nextScheduledRun = useMemo(() => { + if (!scheduleConfig.enabled) return null; + const startTime = scheduleConfig.startTime || "22:00"; + const frequencyHours = scheduleConfig.clockFrequencyHours || 24; + const cronExpression = buildClockCronExpression(startTime, frequencyHours); + if (!cronExpression) return null; + try { + return getNextCronOccurrence(cronExpression, new Date(), effectiveTimezone); + } catch { + return null; + } + }, [scheduleConfig.enabled, scheduleConfig.startTime, scheduleConfig.clockFrequencyHours, effectiveTimezone]); + // Update nextRun for cleanup when settings change useEffect(() => { if (cleanupConfig.enabled && !cleanupConfig.nextRun) { @@ -125,7 +150,7 @@ export function AutomationSettings({

{/* Automatic Syncing Section */} -
+

@@ -136,14 +161,21 @@ export function AutomationSettings({ )}

-
+
- onScheduleChange({ ...scheduleConfig, enabled: !!checked }) + onScheduleChange({ + ...scheduleConfig, + enabled: !!checked, + timezone: checked ? browserTimezone : scheduleConfig.timezone, + startTime: scheduleConfig.startTime || "22:00", + clockFrequencyHours: scheduleConfig.clockFrequencyHours || 24, + scheduleMode: "clock", + }) } />
@@ -154,79 +186,123 @@ export function AutomationSettings({ Enable automatic repository syncing

- Periodically check GitHub for changes and mirror them to Gitea + Periodically sync GitHub changes to Gitea

{scheduleConfig.enabled && ( -
-
- - +
+
+

+ Schedule +

+ + + {effectiveTimezone} + +
+ +
+
+ + +
+ +
+ +
+
+ +
+ + onScheduleChange({ + ...scheduleConfig, + scheduleMode: "clock", + startTime: event.target.value, + clockFrequencyHours: + scheduleConfig.clockFrequencyHours || 24, + timezone: effectiveTimezone, + }) + } + className="appearance-none pl-9 dark:bg-input/30 [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" + /> +
+
)} -
-
- - - Last sync - - +
+ + + Last sync{" "} + {scheduleConfig.lastRun ? formatDate(scheduleConfig.lastRun) : "Never"} -
+
{scheduleConfig.enabled ? ( - scheduleConfig.nextRun && ( -
- - - Next sync - - - {formatDate(scheduleConfig.nextRun)} - -
- ) + + + Next sync{" "} + + {scheduleConfig.nextRun + ? formatDate(scheduleConfig.nextRun) + : nextScheduledRun + ? formatDate(nextScheduledRun) + : "Calculating..."} + + ) : ( -
- Enable automatic syncing to schedule periodic repository updates -
+ Enable syncing to schedule updates )} -
+
{/* Database Cleanup Section */} -
+

@@ -237,7 +313,7 @@ export function AutomationSettings({ )}

-
+

- Remove old activity logs and events to optimize storage + Remove old activity logs to optimize storage

{cleanupConfig.enabled && ( -
+
)} -
+
diff --git a/src/lib/scheduler-service.ts b/src/lib/scheduler-service.ts index 105847d..cabb554 100644 --- a/src/lib/scheduler-service.ts +++ b/src/lib/scheduler-service.ts @@ -8,34 +8,72 @@ import { db, configs, repositories } from '@/lib/db'; import { eq, and, or } from 'drizzle-orm'; import { syncGiteaRepo, mirrorGithubRepoToGitea } from '@/lib/gitea'; import { getDecryptedGitHubToken } from '@/lib/utils/config-encryption'; -import { parseInterval, formatDuration } from '@/lib/utils/duration-parser'; +import { formatDuration } from '@/lib/utils/duration-parser'; import type { Repository } from '@/lib/db/schema'; import { repoStatusEnum, repositoryVisibilityEnum } from '@/types/Repository'; import { mergeGitReposPreferStarred, normalizeGitRepoToInsert, calcBatchSizeForInsert } from '@/lib/repo-utils'; import { isMirrorableGitHubRepo } from '@/lib/repo-eligibility'; import { createMirrorJob } from '@/lib/helpers'; +import { getNextScheduledRun, isCronExpression, normalizeTimezone } from '@/lib/utils/schedule-utils'; let schedulerInterval: NodeJS.Timeout | null = null; let isSchedulerRunning = false; let hasPerformedAutoStart = false; // Track if we've already done auto-start -/** - * Parse schedule interval with enhanced support for duration strings, cron, and numbers - * Supports formats like: "8h", "30m", "24h", "0 0/2 * * *", or plain numbers (seconds) - */ -function parseScheduleInterval(interval: string | number): number { +function resolveScheduleSettings(config: any): { source: string | number; timezone: string } { + const scheduleConfig = config.scheduleConfig || {}; + const source = scheduleConfig.interval || + config.giteaConfig?.mirrorInterval || + '1h'; + const timezone = normalizeTimezone(scheduleConfig.timezone || 'UTC'); + + return { source, timezone }; +} + +function calculateNextRun(config: any, currentTime: Date): Date { + const { source, timezone } = resolveScheduleSettings(config); + try { - const milliseconds = parseInterval(interval); - console.log(`[Scheduler] Parsed interval "${interval}" as ${formatDuration(milliseconds)}`); - return milliseconds; + return getNextScheduledRun(source, currentTime, timezone); } catch (error) { - console.error(`[Scheduler] Failed to parse interval "${interval}": ${error instanceof Error ? error.message : 'Unknown error'}`); - const defaultInterval = 60 * 60 * 1000; // 1 hour - console.log(`[Scheduler] Using default interval: ${formatDuration(defaultInterval)}`); - return defaultInterval; + console.error( + `[Scheduler] Failed to calculate next run from source "${String(source)}" (timezone=${timezone}): ${ + error instanceof Error ? error.message : 'Unknown error' + }` + ); + const fallbackMs = 60 * 60 * 1000; // 1 hour + return new Date(currentTime.getTime() + fallbackMs); } } +function logNextRun(userId: string, source: string | number, timezone: string, currentTime: Date, nextRun: Date): void { + const deltaMs = Math.max(0, nextRun.getTime() - currentTime.getTime()); + const scheduleKind = isCronExpression(source) ? 'cron' : 'interval'; + console.log( + `[Scheduler] Next sync for user ${userId} scheduled for: ${nextRun.toISOString()} ` + + `(in ${formatDuration(deltaMs)}) using ${scheduleKind} "${String(source)}" [timezone=${timezone}]` + ); +} + +async function persistScheduleRunState(config: any, currentTime: Date, forceEnabled = false): Promise { + const scheduleConfig = config.scheduleConfig || {}; + const { source, timezone } = resolveScheduleSettings(config); + const nextRun = calculateNextRun(config, currentTime); + + await db.update(configs).set({ + scheduleConfig: { + ...scheduleConfig, + ...(forceEnabled ? { enabled: true } : {}), + lastRun: currentTime, + nextRun, + }, + updatedAt: currentTime, + }).where(eq(configs.id, config.id)); + + logNextRun(config.userId, source, timezone, currentTime, nextRun); + return nextRun; +} + /** * Run scheduled mirror sync for a single user configuration */ @@ -53,29 +91,9 @@ async function runScheduledSync(config: any): Promise { // Update lastRun timestamp const currentTime = new Date(); const scheduleConfig = config.scheduleConfig || {}; - - // Priority order: scheduleConfig.interval > giteaConfig.mirrorInterval > default - const intervalSource = scheduleConfig.interval || - config.giteaConfig?.mirrorInterval || - '1h'; // Default to 1 hour instead of 3600 seconds - - console.log(`[Scheduler] Using interval source for user ${userId}: ${intervalSource}`); - const interval = parseScheduleInterval(intervalSource); - - // Note: The interval timing is calculated from the LAST RUN time, not from container startup - // This means if GITEA_MIRROR_INTERVAL=8h, the next sync will be 8 hours from the last completed sync - const nextRun = new Date(currentTime.getTime() + interval); - - console.log(`[Scheduler] Next sync for user ${userId} scheduled for: ${nextRun.toISOString()} (in ${formatDuration(interval)})`); - - await db.update(configs).set({ - scheduleConfig: { - ...scheduleConfig, - lastRun: currentTime, - nextRun: nextRun, - }, - updatedAt: currentTime, - }).where(eq(configs.id, config.id)); + const { source, timezone } = resolveScheduleSettings(config); + console.log(`[Scheduler] Using schedule source for user ${userId}: ${String(source)} (timezone=${timezone})`); + await persistScheduleRunState(config, currentTime); // Auto-discovery: Check for new GitHub repositories if (scheduleConfig.autoImport !== false) { @@ -553,22 +571,7 @@ async function performInitialAutoStart(): Promise { // Still update the schedule config to indicate scheduling is active const currentTime = new Date(); - const intervalSource = config.scheduleConfig?.interval || - config.giteaConfig?.mirrorInterval || - '8h'; - const interval = parseScheduleInterval(intervalSource); - const nextRun = new Date(currentTime.getTime() + interval); - - await db.update(configs).set({ - scheduleConfig: { - ...config.scheduleConfig, - enabled: true, - lastRun: currentTime, - nextRun: nextRun, - }, - updatedAt: currentTime, - }).where(eq(configs.id, config.id)); - + const nextRun = await persistScheduleRunState(config, currentTime, true); console.log(`[Scheduler] Scheduling enabled for user ${config.userId}, next sync at ${nextRun.toISOString()}`); continue; } @@ -580,21 +583,7 @@ async function performInitialAutoStart(): Promise { // Still update schedule config timestamps const currentTime2 = new Date(); - const intervalSource2 = config.scheduleConfig?.interval || - config.giteaConfig?.mirrorInterval || - '8h'; - const interval2 = parseScheduleInterval(intervalSource2); - const nextRun2 = new Date(currentTime2.getTime() + interval2); - - await db.update(configs).set({ - scheduleConfig: { - ...config.scheduleConfig, - enabled: true, - lastRun: currentTime2, - nextRun: nextRun2, - }, - updatedAt: currentTime2, - }).where(eq(configs.id, config.id)); + const nextRun2 = await persistScheduleRunState(config, currentTime2, true); console.log(`[Scheduler] Scheduling enabled for user ${config.userId}, next sync at ${nextRun2.toISOString()}`); continue; @@ -681,21 +670,7 @@ async function performInitialAutoStart(): Promise { // Update the schedule config to indicate we've run const currentTime = new Date(); - const intervalSource = config.scheduleConfig?.interval || - config.giteaConfig?.mirrorInterval || - '8h'; - const interval = parseScheduleInterval(intervalSource); - const nextRun = new Date(currentTime.getTime() + interval); - - await db.update(configs).set({ - scheduleConfig: { - ...config.scheduleConfig, - enabled: true, // Ensure scheduling is enabled - lastRun: currentTime, - nextRun: nextRun, - }, - updatedAt: currentTime, - }).where(eq(configs.id, config.id)); + const nextRun = await persistScheduleRunState(config, currentTime, true); console.log(`[Scheduler] Auto-start completed for user ${config.userId}, next sync at ${nextRun.toISOString()}`); @@ -772,6 +747,25 @@ async function schedulerLoop(): Promise { for (const config of validConfigs) { const scheduleConfig = config.scheduleConfig || {}; + const { source, timezone } = resolveScheduleSettings(config); + + // For clock-based schedules, initialize nextRun instead of running immediately. + if (!scheduleConfig.nextRun && isCronExpression(source)) { + const initializedNextRun = calculateNextRun(config, currentTime); + await db.update(configs).set({ + scheduleConfig: { + ...scheduleConfig, + nextRun: initializedNextRun, + }, + updatedAt: currentTime, + }).where(eq(configs.id, config.id)); + + console.log( + `[Scheduler] Initialized next run for user ${config.userId}: ${initializedNextRun.toISOString()} ` + + `from cron "${source}" [timezone=${timezone}]` + ); + continue; + } // Check if it's time to run based on nextRun if (scheduleConfig.nextRun && new Date(scheduleConfig.nextRun) > currentTime) { diff --git a/src/lib/utils/config-defaults.ts b/src/lib/utils/config-defaults.ts index be05c06..b6c6503 100644 --- a/src/lib/utils/config-defaults.ts +++ b/src/lib/utils/config-defaults.ts @@ -2,6 +2,7 @@ import { db, configs } from "@/lib/db"; import { eq } from "drizzle-orm"; import { v4 as uuidv4 } from "uuid"; import { encrypt } from "@/lib/utils/encryption"; +import { getNextScheduledRun, normalizeTimezone } from "@/lib/utils/schedule-utils"; export interface DefaultConfigOptions { userId: string; @@ -13,7 +14,7 @@ export interface DefaultConfigOptions { giteaToken?: string; giteaUsername?: string; scheduleEnabled?: boolean; - scheduleInterval?: number; + scheduleInterval?: number | string; cleanupEnabled?: boolean; cleanupRetentionDays?: number; }; @@ -47,8 +48,17 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default // Schedule config from env - default to ENABLED const scheduleEnabled = envOverrides.scheduleEnabled ?? (process.env.SCHEDULE_ENABLED === "false" ? false : true); // Default: ENABLED - const scheduleInterval = envOverrides.scheduleInterval ?? - (process.env.SCHEDULE_INTERVAL ? parseInt(process.env.SCHEDULE_INTERVAL, 10) : 86400); // Default: daily + const scheduleInterval = envOverrides.scheduleInterval ?? + (process.env.SCHEDULE_INTERVAL || 86400); // Default: daily + const scheduleTimezone = normalizeTimezone(process.env.SCHEDULE_TIMEZONE || "UTC"); + let scheduleNextRun: Date | null = null; + if (scheduleEnabled) { + try { + scheduleNextRun = getNextScheduledRun(scheduleInterval, new Date(), scheduleTimezone); + } catch { + scheduleNextRun = new Date(Date.now() + 86400 * 1000); + } + } // Cleanup config from env - default to ENABLED const cleanupEnabled = envOverrides.cleanupEnabled ?? @@ -104,11 +114,12 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default exclude: [], scheduleConfig: { enabled: scheduleEnabled, - interval: scheduleInterval, + interval: String(scheduleInterval), + timezone: scheduleTimezone, concurrent: false, batchSize: 5, // Reduced from 10 to be more conservative with GitHub API limits lastRun: null, - nextRun: scheduleEnabled ? new Date(Date.now() + scheduleInterval * 1000) : null, + nextRun: scheduleNextRun, }, cleanupConfig: { enabled: cleanupEnabled, diff --git a/src/lib/utils/config-mapper.test.ts b/src/lib/utils/config-mapper.test.ts new file mode 100644 index 0000000..666785d --- /dev/null +++ b/src/lib/utils/config-mapper.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from "bun:test"; +import { mapDbScheduleToUi, mapUiScheduleToDb } from "./config-mapper"; +import { scheduleConfigSchema } from "@/lib/db/schema"; + +test("mapUiScheduleToDb - builds cron from start time + frequency", () => { + const existing = scheduleConfigSchema.parse({}); + const mapped = mapUiScheduleToDb( + { + enabled: true, + scheduleMode: "clock", + clockFrequencyHours: 24, + startTime: "22:00", + timezone: "Asia/Kolkata", + }, + existing + ); + + expect(mapped.enabled).toBe(true); + expect(mapped.interval).toBe("0 22 * * *"); + expect(mapped.timezone).toBe("Asia/Kolkata"); +}); + +test("mapDbScheduleToUi - infers clock mode for generated cron", () => { + const mapped = mapDbScheduleToUi( + scheduleConfigSchema.parse({ + enabled: true, + interval: "15 22,6,14 * * *", + timezone: "Asia/Kolkata", + }) + ); + + expect(mapped.scheduleMode).toBe("clock"); + expect(mapped.clockFrequencyHours).toBe(8); + expect(mapped.startTime).toBe("22:15"); + expect(mapped.timezone).toBe("Asia/Kolkata"); +}); diff --git a/src/lib/utils/config-mapper.ts b/src/lib/utils/config-mapper.ts index 812bb1f..933c1b6 100644 --- a/src/lib/utils/config-mapper.ts +++ b/src/lib/utils/config-mapper.ts @@ -12,6 +12,7 @@ import type { import { z } from "zod"; import { githubConfigSchema, giteaConfigSchema, scheduleConfigSchema, cleanupConfigSchema } from "@/lib/db/schema"; import { parseInterval } from "@/lib/utils/duration-parser"; +import { buildClockCronExpression, normalizeTimezone, parseClockCronExpression } from "@/lib/utils/schedule-utils"; // Use the actual database schema types type DbGitHubConfig = z.infer; @@ -197,15 +198,42 @@ export function mapUiScheduleToDb(uiSchedule: any, existing?: DbScheduleConfig): ? { ...(existing as unknown as DbScheduleConfig) } : (scheduleConfigSchema.parse({}) as unknown as DbScheduleConfig); - // Store interval as seconds string to avoid lossy cron conversion - const intervalSeconds = typeof uiSchedule.interval === 'number' && uiSchedule.interval > 0 - ? String(uiSchedule.interval) - : (typeof base.interval === 'string' ? base.interval : String(86400)); + const baseInterval = typeof base.interval === "string" + ? base.interval + : String(base.interval ?? 86400); + + const timezone = normalizeTimezone( + typeof uiSchedule.timezone === "string" + ? uiSchedule.timezone + : base.timezone || "UTC" + ); + + let intervalExpression = baseInterval; + + if (uiSchedule.scheduleMode === "clock") { + const cronExpression = buildClockCronExpression( + uiSchedule.startTime || "22:00", + Number(uiSchedule.clockFrequencyHours || 24) + ); + if (cronExpression) { + intervalExpression = cronExpression; + } + } else if (typeof uiSchedule.intervalExpression === "string" && uiSchedule.intervalExpression.trim().length > 0) { + intervalExpression = uiSchedule.intervalExpression.trim(); + } else if (typeof uiSchedule.interval === "number" && Number.isFinite(uiSchedule.interval) && uiSchedule.interval > 0) { + intervalExpression = String(Math.floor(uiSchedule.interval)); + } else if (typeof uiSchedule.interval === "string" && uiSchedule.interval.trim().length > 0) { + intervalExpression = uiSchedule.interval.trim(); + } + + const scheduleChanged = baseInterval !== intervalExpression || normalizeTimezone(base.timezone || "UTC") !== timezone; return { ...base, enabled: !!uiSchedule.enabled, - interval: intervalSeconds, + interval: intervalExpression, + timezone, + nextRun: scheduleChanged ? undefined : base.nextRun, } as DbScheduleConfig; } @@ -218,11 +246,21 @@ export function mapDbScheduleToUi(dbSchedule: DbScheduleConfig): any { return { enabled: false, interval: 86400, // Default to daily (24 hours) + intervalExpression: "86400", + scheduleMode: "interval", + clockFrequencyHours: 24, + startTime: "22:00", + timezone: "UTC", lastRun: null, nextRun: null, }; } + const intervalExpression = typeof dbSchedule.interval === "string" + ? dbSchedule.interval + : String(dbSchedule.interval ?? 86400); + const parsedClockSchedule = parseClockCronExpression(intervalExpression); + // Parse interval supporting numbers (seconds), duration strings, and cron let intervalSeconds = 86400; // Default to daily (24 hours) try { @@ -240,6 +278,11 @@ export function mapDbScheduleToUi(dbSchedule: DbScheduleConfig): any { return { enabled: dbSchedule.enabled || false, interval: intervalSeconds, + intervalExpression, + scheduleMode: parsedClockSchedule ? "clock" : "interval", + clockFrequencyHours: parsedClockSchedule?.frequencyHours ?? 24, + startTime: parsedClockSchedule?.startTime ?? "22:00", + timezone: normalizeTimezone(dbSchedule.timezone || "UTC"), lastRun: dbSchedule.lastRun || null, nextRun: dbSchedule.nextRun || null, }; diff --git a/src/lib/utils/schedule-utils.test.ts b/src/lib/utils/schedule-utils.test.ts new file mode 100644 index 0000000..36f45c4 --- /dev/null +++ b/src/lib/utils/schedule-utils.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from "bun:test"; +import { + buildClockCronExpression, + getNextCronOccurrence, + getNextScheduledRun, + isCronExpression, + normalizeTimezone, + parseClockCronExpression, +} from "./schedule-utils"; + +test("isCronExpression - detects 5-part cron expressions", () => { + expect(isCronExpression("0 22 * * *")).toBe(true); + expect(isCronExpression("8h")).toBe(false); + expect(isCronExpression(3600)).toBe(false); +}); + +test("buildClockCronExpression - creates daily and hourly expressions", () => { + expect(buildClockCronExpression("22:00", 24)).toBe("0 22 * * *"); + expect(buildClockCronExpression("22:15", 8)).toBe("15 22,6,14 * * *"); + expect(buildClockCronExpression("10:30", 1)).toBe("30 * * * *"); + expect(buildClockCronExpression("10:30", 7)).toBeNull(); +}); + +test("parseClockCronExpression - parses generated expressions", () => { + expect(parseClockCronExpression("0 22 * * *")).toEqual({ + startTime: "22:00", + frequencyHours: 24, + }); + expect(parseClockCronExpression("15 22,6,14 * * *")).toEqual({ + startTime: "22:15", + frequencyHours: 8, + }); + expect(parseClockCronExpression("30 * * * *")).toEqual({ + startTime: "00:30", + frequencyHours: 1, + }); + expect(parseClockCronExpression("0 3 * * 1-5")).toBeNull(); +}); + +test("getNextCronOccurrence - computes next run in UTC", () => { + const from = new Date("2026-03-18T15:20:00.000Z"); + const next = getNextCronOccurrence("0 22 * * *", from, "UTC"); + expect(next.toISOString()).toBe("2026-03-18T22:00:00.000Z"); +}); + +test("getNextCronOccurrence - respects timezone", () => { + const from = new Date("2026-03-18T15:20:00.000Z"); + // 22:00 IST equals 16:30 UTC + const next = getNextCronOccurrence("0 22 * * *", from, "Asia/Kolkata"); + expect(next.toISOString()).toBe("2026-03-18T16:30:00.000Z"); +}); + +test("getNextScheduledRun - handles interval and cron schedules", () => { + const from = new Date("2026-03-18T00:00:00.000Z"); + const intervalNext = getNextScheduledRun("8h", from, "UTC"); + expect(intervalNext.toISOString()).toBe("2026-03-18T08:00:00.000Z"); + + const cronNext = getNextScheduledRun("0 */6 * * *", from, "UTC"); + expect(cronNext.toISOString()).toBe("2026-03-18T06:00:00.000Z"); +}); + +test("normalizeTimezone - falls back to UTC for invalid values", () => { + expect(normalizeTimezone("Invalid/Zone")).toBe("UTC"); + expect(normalizeTimezone("Asia/Kolkata")).toBe("Asia/Kolkata"); +}); diff --git a/src/lib/utils/schedule-utils.ts b/src/lib/utils/schedule-utils.ts new file mode 100644 index 0000000..7070220 --- /dev/null +++ b/src/lib/utils/schedule-utils.ts @@ -0,0 +1,420 @@ +import { parseInterval } from "@/lib/utils/duration-parser"; + +const WEEKDAY_INDEX: Record = { + sun: 0, + mon: 1, + tue: 2, + wed: 3, + thu: 4, + fri: 5, + sat: 6, +}; + +const MONTH_INDEX: Record = { + jan: 1, + feb: 2, + mar: 3, + apr: 4, + may: 5, + jun: 6, + jul: 7, + aug: 8, + sep: 9, + oct: 10, + nov: 11, + dec: 12, +}; + +interface ParsedCronField { + wildcard: boolean; + values: Set; +} + +interface ZonedDateParts { + minute: number; + hour: number; + dayOfMonth: number; + month: number; + dayOfWeek: number; +} + +interface ParsedCronExpression { + minute: ParsedCronField; + hour: ParsedCronField; + dayOfMonth: ParsedCronField; + month: ParsedCronField; + dayOfWeek: ParsedCronField; +} + +const zonedPartsFormatterCache = new Map(); +const zonedWeekdayFormatterCache = new Map(); + +function pad2(value: number): string { + return value.toString().padStart(2, "0"); +} + +export function isCronExpression(value: unknown): value is string { + return typeof value === "string" && value.trim().split(/\s+/).length === 5; +} + +export function normalizeTimezone(timezone?: string): string { + const candidate = timezone?.trim() || "UTC"; + try { + // Validate timezone eagerly. + new Intl.DateTimeFormat("en-US", { timeZone: candidate }); + return candidate; + } catch { + return "UTC"; + } +} + +function getZonedPartsFormatter(timezone: string): Intl.DateTimeFormat { + const cacheKey = normalizeTimezone(timezone); + const cached = zonedPartsFormatterCache.get(cacheKey); + if (cached) return cached; + + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone: cacheKey, + hour12: false, + hourCycle: "h23", + month: "numeric", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + + zonedPartsFormatterCache.set(cacheKey, formatter); + return formatter; +} + +function getZonedWeekdayFormatter(timezone: string): Intl.DateTimeFormat { + const cacheKey = normalizeTimezone(timezone); + const cached = zonedWeekdayFormatterCache.get(cacheKey); + if (cached) return cached; + + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone: cacheKey, + weekday: "short", + }); + + zonedWeekdayFormatterCache.set(cacheKey, formatter); + return formatter; +} + +function getZonedDateParts(date: Date, timezone: string): ZonedDateParts { + const safeTimezone = normalizeTimezone(timezone); + const parts = getZonedPartsFormatter(safeTimezone).formatToParts(date); + + const month = Number(parts.find((part) => part.type === "month")?.value); + const dayOfMonth = Number(parts.find((part) => part.type === "day")?.value); + const hour = Number(parts.find((part) => part.type === "hour")?.value); + const minute = Number(parts.find((part) => part.type === "minute")?.value); + + const weekdayLabel = getZonedWeekdayFormatter(safeTimezone) + .format(date) + .toLowerCase() + .slice(0, 3); + const dayOfWeek = WEEKDAY_INDEX[weekdayLabel]; + + if ( + Number.isNaN(month) || + Number.isNaN(dayOfMonth) || + Number.isNaN(hour) || + Number.isNaN(minute) || + typeof dayOfWeek !== "number" + ) { + throw new Error("Unable to extract timezone-aware date parts"); + } + + return { + month, + dayOfMonth, + hour, + minute, + dayOfWeek, + }; +} + +function parseCronAtom( + atom: string, + min: number, + max: number, + aliases?: Record, + allowSevenAsSunday = false +): number { + const normalized = atom.trim().toLowerCase(); + if (normalized.length === 0) { + throw new Error("Empty cron atom"); + } + + const aliasValue = aliases?.[normalized]; + const parsed = aliasValue ?? Number(normalized); + if (!Number.isInteger(parsed)) { + throw new Error(`Invalid cron value: "${atom}"`); + } + + const normalizedDowValue = allowSevenAsSunday && parsed === 7 ? 0 : parsed; + if (normalizedDowValue < min || normalizedDowValue > max) { + throw new Error( + `Cron value "${atom}" out of range (${min}-${max})` + ); + } + + return normalizedDowValue; +} + +function addRangeValues( + target: Set, + start: number, + end: number, + step: number, + min: number, + max: number +): void { + if (step <= 0) { + throw new Error(`Invalid cron step: ${step}`); + } + if (start < min || end > max || start > end) { + throw new Error(`Invalid cron range: ${start}-${end}`); + } + + for (let value = start; value <= end; value += step) { + target.add(value); + } +} + +function parseCronField( + field: string, + min: number, + max: number, + aliases?: Record, + allowSevenAsSunday = false +): ParsedCronField { + const raw = field.trim(); + if (raw === "*") { + const values = new Set(); + for (let i = min; i <= max; i += 1) values.add(i); + return { wildcard: true, values }; + } + + const values = new Set(); + const segments = raw.split(","); + for (const segment of segments) { + const trimmedSegment = segment.trim(); + if (!trimmedSegment) { + throw new Error(`Invalid cron field "${field}"`); + } + + const [basePart, stepPart] = trimmedSegment.split("/"); + const step = stepPart ? Number(stepPart) : 1; + if (!Number.isInteger(step) || step <= 0) { + throw new Error(`Invalid cron step "${stepPart}"`); + } + + if (basePart === "*") { + addRangeValues(values, min, max, step, min, max); + continue; + } + + if (basePart.includes("-")) { + const [startRaw, endRaw] = basePart.split("-"); + const start = parseCronAtom( + startRaw, + min, + max, + aliases, + allowSevenAsSunday + ); + const end = parseCronAtom( + endRaw, + min, + max, + aliases, + allowSevenAsSunday + ); + addRangeValues(values, start, end, step, min, max); + continue; + } + + const value = parseCronAtom( + basePart, + min, + max, + aliases, + allowSevenAsSunday + ); + values.add(value); + } + + return { wildcard: false, values }; +} + +function parseCronExpression(expression: string): ParsedCronExpression { + const parts = expression.trim().split(/\s+/); + if (parts.length !== 5) { + throw new Error( + 'Cron expression must have 5 parts: "minute hour day month weekday"' + ); + } + + const [minute, hour, dayOfMonth, month, dayOfWeek] = parts; + return { + minute: parseCronField(minute, 0, 59), + hour: parseCronField(hour, 0, 23), + dayOfMonth: parseCronField(dayOfMonth, 1, 31), + month: parseCronField(month, 1, 12, MONTH_INDEX), + dayOfWeek: parseCronField(dayOfWeek, 0, 6, WEEKDAY_INDEX, true), + }; +} + +function matchesCron( + cron: ParsedCronExpression, + parts: ZonedDateParts +): boolean { + if (!cron.minute.values.has(parts.minute)) return false; + if (!cron.hour.values.has(parts.hour)) return false; + if (!cron.month.values.has(parts.month)) return false; + + const dayOfMonthWildcard = cron.dayOfMonth.wildcard; + const dayOfWeekWildcard = cron.dayOfWeek.wildcard; + const dayOfMonthMatches = cron.dayOfMonth.values.has(parts.dayOfMonth); + const dayOfWeekMatches = cron.dayOfWeek.values.has(parts.dayOfWeek); + + if (dayOfMonthWildcard && dayOfWeekWildcard) return true; + if (dayOfMonthWildcard) return dayOfWeekMatches; + if (dayOfWeekWildcard) return dayOfMonthMatches; + return dayOfMonthMatches || dayOfWeekMatches; +} + +export function getNextCronOccurrence( + expression: string, + fromDate: Date, + timezone = "UTC", + maxLookaheadMinutes = 2 * 365 * 24 * 60 +): Date { + const cron = parseCronExpression(expression); + const safeTimezone = normalizeTimezone(timezone); + + const base = new Date(fromDate); + base.setSeconds(0, 0); + const firstCandidateMs = base.getTime() + 60_000; + + for (let offset = 0; offset <= maxLookaheadMinutes; offset += 1) { + const candidate = new Date(firstCandidateMs + offset * 60_000); + const candidateParts = getZonedDateParts(candidate, safeTimezone); + if (matchesCron(cron, candidateParts)) { + return candidate; + } + } + + throw new Error( + `Could not find next cron occurrence for "${expression}" within ${maxLookaheadMinutes} minutes` + ); +} + +export function getNextScheduledRun( + schedule: string | number, + fromDate: Date, + timezone = "UTC" +): Date { + if (isCronExpression(schedule)) { + return getNextCronOccurrence(schedule, fromDate, timezone); + } + + const intervalMs = parseInterval(schedule); + return new Date(fromDate.getTime() + intervalMs); +} + +export function buildClockCronExpression( + startTime: string, + frequencyHours: number +): string | null { + const parsed = startTime.match(/^([01]\d|2[0-3]):([0-5]\d)$/); + if (!parsed) return null; + + if (!Number.isInteger(frequencyHours) || frequencyHours <= 0) { + return null; + } + + const hour = Number(parsed[1]); + const minute = Number(parsed[2]); + + if (frequencyHours === 24) { + return `${minute} ${hour} * * *`; + } + + if (frequencyHours === 1) { + return `${minute} * * * *`; + } + + if (24 % frequencyHours !== 0) { + return null; + } + + const hourCount = 24 / frequencyHours; + const hours: number[] = []; + for (let i = 0; i < hourCount; i += 1) { + hours.push((hour + i * frequencyHours) % 24); + } + + return `${minute} ${hours.join(",")} * * *`; +} + +export function parseClockCronExpression( + expression: string +): { startTime: string; frequencyHours: number } | null { + const parts = expression.trim().split(/\s+/); + if (parts.length !== 5) return null; + + const [minuteRaw, hourRaw, dayRaw, monthRaw, weekdayRaw] = parts; + if (dayRaw !== "*" || monthRaw !== "*" || weekdayRaw !== "*") { + return null; + } + + const minute = Number(minuteRaw); + if (!Number.isInteger(minute) || minute < 0 || minute > 59) { + return null; + } + + if (hourRaw === "*") { + return { + startTime: `00:${pad2(minute)}`, + frequencyHours: 1, + }; + } + + const hourTokens = hourRaw.split(","); + if (hourTokens.length === 0) return null; + + const hours = hourTokens.map((token) => Number(token)); + if (hours.some((hour) => !Number.isInteger(hour) || hour < 0 || hour > 23)) { + return null; + } + + if (hours.length === 1) { + return { + startTime: `${pad2(hours[0])}:${pad2(minute)}`, + frequencyHours: 24, + }; + } + + // Verify evenly spaced circular sequence to infer "every N hours". + const deltas: number[] = []; + for (let i = 0; i < hours.length; i += 1) { + const current = hours[i]; + const next = i === hours.length - 1 ? hours[0] : hours[i + 1]; + const delta = (next - current + 24) % 24; + deltas.push(delta); + } + + const expectedDelta = deltas[0]; + const uniform = deltas.every((delta) => delta === expectedDelta && delta > 0); + if (!uniform || expectedDelta <= 0 || 24 % expectedDelta !== 0) { + return null; + } + + return { + startTime: `${pad2(hours[0])}:${pad2(minute)}`, + frequencyHours: expectedDelta, + }; +} diff --git a/src/pages/api/job/schedule-sync-repo.ts b/src/pages/api/job/schedule-sync-repo.ts index a2223c5..49e8e03 100644 --- a/src/pages/api/job/schedule-sync-repo.ts +++ b/src/pages/api/job/schedule-sync-repo.ts @@ -8,7 +8,7 @@ import type { ScheduleSyncRepoResponse, } from "@/types/sync"; import { createSecureErrorResponse } from "@/lib/utils"; -import { parseInterval } from "@/lib/utils/duration-parser"; +import { getNextScheduledRun, normalizeTimezone } from "@/lib/utils/schedule-utils"; import { requireAuthenticatedUserId } from "@/lib/auth-guards"; export const POST: APIRoute = async ({ request, locals }) => { @@ -68,17 +68,17 @@ export const POST: APIRoute = async ({ request, locals }) => { // Calculate nextRun and update lastRun and nextRun in the config const currentTime = new Date(); - let intervalMs = 3600 * 1000; + const scheduleSource = + config.scheduleConfig?.interval || + config.giteaConfig?.mirrorInterval || + "3600"; + const timezone = normalizeTimezone(config.scheduleConfig?.timezone || "UTC"); + let nextRun = new Date(currentTime.getTime() + 3600 * 1000); try { - intervalMs = parseInterval( - typeof config.scheduleConfig?.interval === 'number' - ? config.scheduleConfig.interval - : (config.scheduleConfig?.interval as unknown as string) || '3600' - ); + nextRun = getNextScheduledRun(scheduleSource, currentTime, timezone); } catch { - intervalMs = 3600 * 1000; + nextRun = new Date(currentTime.getTime() + 3600 * 1000); } - const nextRun = new Date(currentTime.getTime() + intervalMs); // Update the full giteaConfig object await db diff --git a/src/types/config.ts b/src/types/config.ts index bc4cae9..acefcbf 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -4,6 +4,7 @@ export type GiteaOrgVisibility = "public" | "private" | "limited"; export type MirrorStrategy = "preserve" | "single-org" | "flat-user" | "mixed"; export type StarredReposMode = "dedicated-org" | "preserve-owner"; export type BackupStrategy = "disabled" | "always" | "on-force-push" | "block-on-force-push"; +export type ScheduleMode = "interval" | "clock"; export interface GiteaConfig { url: string; @@ -29,7 +30,12 @@ export interface GiteaConfig { export interface ScheduleConfig { enabled: boolean; - interval: number; + interval: number | string; + intervalExpression?: string; + scheduleMode?: ScheduleMode; + clockFrequencyHours?: number; + startTime?: string; + timezone?: string; lastRun?: Date; nextRun?: Date; }