fix(settings): show the schedule that actually runs on the automation card (#435)

A fresh install started with scheduling enabled on a plain 24 hour
interval counted from first login. The automation card only knows clock
schedules, so it showed the 22:00 placeholder as if it were saved while
the scheduler ran at whatever time the user first signed in. Toggling
the card off and on happened to store the clock schedule and hide the
mismatch (#427). The card also showed the browser timezone whenever UTC
was stored, so a stored 22:00 UTC read as 22:00 local.

New configs start with scheduling off unless SCHEDULE_ENABLED or an
interval variable turns it on, with the same rule as the environment
loader, and the built-in default is the 22:00 clock schedule the card
shows. When a plain interval is stored (SCHEDULE_INTERVAL,
GITEA_MIRROR_INTERVAL or an older version) the card says what runs and
how it got there, and leaves the frequency and start time unset until
the user picks one. The timezone chip shows the stored timezone and
turns into a one-click switch to the browser timezone when they differ.
This commit is contained in:
ARUNAVO RAY authored and GitHub committed 2026-09-16 07:39:22 +05:30
1 parent 2ff2e71d18
commit c8618743c8
3 files changed
+100 -20

No files matched your search

+73 -16
View File
@@ -68,6 +68,24 @@ const retentionPeriods = [
{ label: "3 months", value: 7776000 },
];
/** "every 8 hours", "every 30 minutes", "every 2 days" for a plain interval. */
function formatIntervalSeconds(interval: number | string | undefined): string {
const seconds = typeof interval === "number" ? interval : Number(interval);
if (!Number.isFinite(seconds) || seconds <= 0) return "on a fixed interval";
const units: [number, string][] = [
[86400, "day"],
[3600, "hour"],
[60, "minute"],
];
for (const [size, name] of units) {
if (seconds % size === 0 || seconds >= size * 2) {
const count = Math.round(seconds / size);
return `every ${count === 1 ? name : `${count} ${name}s`}`;
}
}
return `every ${seconds} seconds`;
}
function getCleanupInterval(retentionSeconds: number): number {
const days = retentionSeconds / 86400;
if (days <= 1) return 21600; // 6 hours
@@ -126,16 +144,22 @@ export function AutomationSettings({
? Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
: "UTC";
// Use saved timezone, but treat "UTC" as unset for users who never chose
// it: older versions stored UTC as a default without asking. Anyone truly
// in UTC gets the same result via their browser timezone.
const effectiveTimezone =
scheduleConfig.timezone && scheduleConfig.timezone !== "UTC"
? scheduleConfig.timezone
: browserTimezone;
// The timezone the scheduler really uses. It used to be shown as the
// browser timezone whenever UTC was stored, which made the card promise a
// local 22:00 while the sync ran at 22:00 UTC (#427). The chip below offers
// a one-click switch to the browser timezone when the two differ.
const effectiveTimezone = scheduleConfig.timezone || browserTimezone;
const timezoneDiffers = effectiveTimezone !== browserTimezone;
// The stored schedule is a plain interval (seconds or a duration such as
// 8h) rather than a clock schedule. That is what SCHEDULE_INTERVAL and
// GITEA_MIRROR_INTERVAL produce, and what older versions stored. The card
// only edits clock schedules, so say what actually runs instead of showing
// the 22:00 placeholder as if it were saved (#427).
const isIntervalSchedule = scheduleConfig.scheduleMode === "interval";
const nextScheduledRun = useMemo(() => {
if (!scheduleConfig.enabled) return null;
if (!scheduleConfig.enabled || isIntervalSchedule) return null;
const startTime = scheduleConfig.startTime || "22:00";
const frequencyHours = scheduleConfig.clockFrequencyHours || 24;
const cronExpression = buildClockCronExpression(startTime, frequencyHours);
@@ -145,7 +169,16 @@ export function AutomationSettings({
} catch {
return null;
}
}, [scheduleConfig.enabled, scheduleConfig.startTime, scheduleConfig.clockFrequencyHours, effectiveTimezone]);
}, [scheduleConfig.enabled, isIntervalSchedule, scheduleConfig.startTime, scheduleConfig.clockFrequencyHours, effectiveTimezone]);
const switchToBrowserTimezone = () =>
onScheduleChange({
...scheduleConfig,
scheduleMode: "clock",
startTime: scheduleConfig.startTime || "22:00",
clockFrequencyHours: scheduleConfig.clockFrequencyHours || 24,
timezone: browserTimezone,
});
// Update nextRun for cleanup when settings change
useEffect(() => {
@@ -217,15 +250,39 @@ export function AutomationSettings({
<CardSection>
<SectionTitle
action={
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2.5 py-1 text-[11px] text-muted-foreground">
<Globe className="h-3 w-3" />
{effectiveTimezone}
</span>
timezoneDiffers ? (
<button
type="button"
onClick={switchToBrowserTimezone}
title={`Switch the schedule to ${browserTimezone}`}
className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2.5 py-1 text-[11px] text-muted-foreground hover:bg-muted/70 hover:text-foreground"
>
<Globe className="h-3 w-3" />
{effectiveTimezone}
<span className="text-muted-foreground/60">· use {browserTimezone}</span>
</button>
) : (
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2.5 py-1 text-[11px] text-muted-foreground">
<Globe className="h-3 w-3" />
{effectiveTimezone}
</span>
)
}
>
Schedule
</SectionTitle>
{isIntervalSchedule && (
<p className="flex items-start gap-2 rounded-md border border-dashed border-border px-3 py-2 text-xs text-muted-foreground">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
Syncing {formatIntervalSeconds(scheduleConfig.interval)}, counted from the last run.
{" "}This interval comes from SCHEDULE_INTERVAL or GITEA_MIRROR_INTERVAL, which is applied again on every restart while it is set, or from an older version.
{" "}Pick a frequency or start time to switch to a clock schedule.
</span>
</p>
)}
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label
@@ -235,7 +292,7 @@ export function AutomationSettings({
Frequency
</Label>
<Select
value={String(scheduleConfig.clockFrequencyHours || 24)}
value={isIntervalSchedule ? "" : String(scheduleConfig.clockFrequencyHours || 24)}
onValueChange={(value) =>
onScheduleChange({
...scheduleConfig,
@@ -247,7 +304,7 @@ export function AutomationSettings({
}
>
<SelectTrigger id="clock-frequency" className="w-full">
<SelectValue />
<SelectValue placeholder="Choose a frequency" />
</SelectTrigger>
<SelectContent>
{clockFrequencies.map((option) => (
@@ -276,7 +333,7 @@ export function AutomationSettings({
<Input
id="clock-start-time"
type="time"
value={scheduleConfig.startTime || "22:00"}
value={isIntervalSchedule ? "" : scheduleConfig.startTime || "22:00"}
onChange={(event) =>
onScheduleChange({
...scheduleConfig,
+10 -4
View File
@@ -47,11 +47,17 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default
const giteaToken = envOverrides.giteaToken || process.env.GITEA_TOKEN || "";
const giteaUsername = envOverrides.giteaUsername || process.env.GITEA_USERNAME || "";
// Schedule config from env - default to ENABLED
const scheduleEnabled = envOverrides.scheduleEnabled ??
(process.env.SCHEDULE_ENABLED === "false" ? false : true); // Default: ENABLED
// Schedule config from env. Off unless the environment turns it on, with
// the same rule as env-config-loader: SCHEDULE_ENABLED=true or any interval
// variable. A fresh install used to start enabled on a plain 24h interval
// counted from first login, which the settings card (clock mode only)
// showed as "22:00 daily" (#427). Enabling from the card stores the clock
// schedule and the browser timezone, so the card and the scheduler agree.
const envInterval = process.env.SCHEDULE_INTERVAL || process.env.GITEA_MIRROR_INTERVAL;
const scheduleEnabled = envOverrides.scheduleEnabled ??
(process.env.SCHEDULE_ENABLED === "true" || !!envInterval);
const scheduleInterval = envOverrides.scheduleInterval ??
(process.env.SCHEDULE_INTERVAL || 86400); // Default: daily
(envInterval || "0 22 * * *"); // Default: daily at 22:00 in scheduleTimezone
const scheduleTimezone = normalizeTimezone(process.env.SCHEDULE_TIMEZONE || "UTC");
let scheduleNextRun: Date | null = null;
if (scheduleEnabled) {
+17
View File
@@ -82,6 +82,23 @@ test("mapDbScheduleToUi - infers clock mode for generated cron", () => {
expect(mapped.timezone).toBe("Asia/Kolkata");
});
test("mapDbScheduleToUi - reports interval mode for a plain interval instead of a clock schedule", () => {
// What SCHEDULE_INTERVAL / GITEA_MIRROR_INTERVAL and older versions store.
// The settings card must not present the 22:00 placeholder as saved (#427).
const mapped = mapDbScheduleToUi(
scheduleConfigSchema.parse({
enabled: true,
interval: "8h",
timezone: "UTC",
})
);
expect(mapped.scheduleMode).toBe("interval");
expect(mapped.interval).toBe(8 * 3600);
expect(mapped.intervalExpression).toBe("8h");
expect(mapped.timezone).toBe("UTC");
});
test("includeCollaboratorRepos round-trips through UI -> DB -> UI when true", () => {
const ui = buildMinimalUiConfigs({ includeCollaboratorRepos: true });
const db = mapUiToDbConfig(