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:
@@ -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>;
|
||||
|
||||
35
packages/shared/src/timezone.test.ts
Normal file
35
packages/shared/src/timezone.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
117
packages/shared/src/timezone.ts
Normal file
117
packages/shared/src/timezone.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user