Enhance scheduler functionality with timezone support and error handling

- Updated scheduling logic to utilize the guild's timezone for cron expressions and one-time scheduling.
- Introduced a new `getGuildTimezone` function to retrieve the timezone from guild settings.
- Enhanced error handling for scheduling inputs, providing localized error messages for invalid cron expressions and missing schedule times.
- Improved the WebUI to support a new timing mode selection (once vs. cron) and updated input fields accordingly.
- Added localization entries for new scheduling-related messages in both English and German.
- Refactored job scheduling and removal logic to ensure proper handling of scheduled jobs in the queue.
This commit is contained in:
TheOnlyMace
2026-07-25 17:04:38 +02:00
parent bc4fae5415
commit f67b9c61d7
11 changed files with 418 additions and 78 deletions

View File

@@ -13,6 +13,7 @@ export * from './owner.js';
export * from './public.js';
export * from './premium.js';
export * from './components-v2.js';
export * from './timezone.js';
export const LocaleSchema = z.enum(['de', 'en']);
export type Locale = z.infer<typeof LocaleSchema>;

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import {
guildLocalDateTimeToUtc,
isValidTimezone,
resolveScheduleRunAt
} from './timezone.js';
describe('timezone helpers', () => {
it('validates IANA timezones', () => {
expect(isValidTimezone('Europe/Berlin')).toBe(true);
expect(isValidTimezone('Not/AZone')).toBe(false);
});
it('converts Berlin winter wall time to UTC', () => {
// 2026-01-15 09:00 Europe/Berlin = 08:00 UTC (CET, UTC+1)
const utc = guildLocalDateTimeToUtc('2026-01-15T09:00', 'Europe/Berlin');
expect(utc.toISOString()).toBe('2026-01-15T08:00:00.000Z');
});
it('converts Berlin summer wall time to UTC', () => {
// 2026-07-15 09:00 Europe/Berlin = 07:00 UTC (CEST, UTC+2)
const utc = guildLocalDateTimeToUtc('2026-07-15T09:00', 'Europe/Berlin');
expect(utc.toISOString()).toBe('2026-07-15T07:00:00.000Z');
});
it('keeps absolute ISO strings unchanged', () => {
const utc = resolveScheduleRunAt('2026-07-15T07:00:00.000Z', 'Europe/Berlin');
expect(utc.toISOString()).toBe('2026-07-15T07:00:00.000Z');
});
it('interprets naive datetime-local in guild timezone', () => {
const utc = resolveScheduleRunAt('2026-07-15T09:00', 'Europe/Berlin');
expect(utc.toISOString()).toBe('2026-07-15T07:00:00.000Z');
});
});

View File

@@ -0,0 +1,117 @@
/** Default guild timezone (matches Prisma `GuildSettings.timezone`). */
export const DEFAULT_GUILD_TIMEZONE = 'Europe/Berlin';
export function isValidTimezone(timezone: string): boolean {
try {
Intl.DateTimeFormat(undefined, { timeZone: timezone });
return true;
} catch {
return false;
}
}
type ZonedParts = {
year: number;
month: number;
day: number;
hour: number;
minute: number;
second: number;
};
function getZonedParts(date: Date, timeZone: string): ZonedParts {
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23'
});
const map: Partial<Record<Intl.DateTimeFormatPartTypes, string>> = {};
for (const part of formatter.formatToParts(date)) {
if (part.type !== 'literal') {
map[part.type] = part.value;
}
}
return {
year: Number(map.year),
month: Number(map.month),
day: Number(map.day),
hour: Number(map.hour),
minute: Number(map.minute),
second: Number(map.second)
};
}
const NAIVE_LOCAL_RE = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/;
/**
* Interprets a wall-clock datetime (`YYYY-MM-DDTHH:mm` / with seconds / space
* separator) as being in `timeZone` and returns the corresponding UTC `Date`.
*/
export function guildLocalDateTimeToUtc(localDateTime: string, timeZone: string): Date {
const match = localDateTime.trim().match(NAIVE_LOCAL_RE);
if (!match) {
throw new RangeError('invalid_local_datetime');
}
if (!isValidTimezone(timeZone)) {
throw new RangeError('invalid_timezone');
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const hour = Number(match[4]);
const minute = Number(match[5]);
const second = Number(match[6] ?? 0);
// Initial guess: treat the components as UTC, then correct by the TZ offset.
let utcMs = Date.UTC(year, month - 1, day, hour, minute, second);
for (let i = 0; i < 3; i += 1) {
const parts = getZonedParts(new Date(utcMs), timeZone);
const asUtc = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
const targetAsUtc = Date.UTC(year, month - 1, day, hour, minute, second);
const diff = targetAsUtc - asUtc;
if (diff === 0) {
break;
}
utcMs += diff;
}
return new Date(utcMs);
}
/**
* Resolves a schedule `runAt` input to a UTC instant.
* - Values with `Z` / numeric offset are treated as absolute.
* - Naive `datetime-local` values are interpreted in the guild timezone.
*/
export function resolveScheduleRunAt(input: string, timeZone: string): Date {
const trimmed = input.trim();
if (!trimmed) {
throw new RangeError('invalid_when');
}
if (/([zZ]|[+-]\d{2}:?\d{2})$/.test(trimmed)) {
const parsed = new Date(trimmed);
if (Number.isNaN(parsed.getTime())) {
throw new RangeError('invalid_when');
}
return parsed;
}
if (NAIVE_LOCAL_RE.test(trimmed)) {
return guildLocalDateTimeToUtc(trimmed, timeZone);
}
const parsed = new Date(trimmed);
if (Number.isNaN(parsed.getTime())) {
throw new RangeError('invalid_when');
}
return parsed;
}