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 0000000..4b611a5 Binary files /dev/null and b/docs/images/issue-240-automation-ui-v2.png differ diff --git a/docs/images/issue-240-automation-ui.png b/docs/images/issue-240-automation-ui.png new file mode 100644 index 0000000..d8d66da Binary files /dev/null and b/docs/images/issue-240-automation-ui.png differ 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; }