From f67b9c61d745be0be8c1863aa6001c6a3bd29298 Mon Sep 17 00:00:00 2001 From: TheOnlyMace <0815cracky@gmail.com> Date: Sat, 25 Jul 2026 17:04:38 +0200 Subject: [PATCH] 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. --- apps/bot/src/modules/scheduler/service.ts | 131 ++++++++++++------ apps/webui/src/app/globals.css | 2 + .../components/modules/scheduler-manager.tsx | 66 +++++++-- apps/webui/src/components/ui/input.tsx | 2 +- .../webui/src/lib/module-configs/scheduler.ts | 101 ++++++++++---- apps/webui/src/messages/de.json | 12 +- apps/webui/src/messages/en.json | 12 +- docs/PHASE-TRACKING.md | 17 +++ packages/shared/src/index.ts | 1 + packages/shared/src/timezone.test.ts | 35 +++++ packages/shared/src/timezone.ts | 117 ++++++++++++++++ 11 files changed, 418 insertions(+), 78 deletions(-) create mode 100644 packages/shared/src/timezone.test.ts create mode 100644 packages/shared/src/timezone.ts diff --git a/apps/bot/src/modules/scheduler/service.ts b/apps/bot/src/modules/scheduler/service.ts index f9e46e5..52274ee 100644 --- a/apps/bot/src/modules/scheduler/service.ts +++ b/apps/bot/src/modules/scheduler/service.ts @@ -4,11 +4,20 @@ import { type MessageCreateOptions, type TextChannel } from 'discord.js'; -import { WelcomeEmbedSchema, expandBracketChannelMentions, parseMessageComponentsV2, t, tf } from '@nexumi/shared'; +import { + DEFAULT_GUILD_TIMEZONE, + WelcomeEmbedSchema, + expandBracketChannelMentions, + parseMessageComponentsV2, + resolveScheduleRunAt, + t, + tf +} from '@nexumi/shared'; import type { ScheduledMessage } from '@prisma/client'; import { ensureGuild } from '../../guild.js'; import { buildEmbedFromPayload } from '../../lib/embed-payload.js'; import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js'; +import { safeRemoveBullJob } from '../../lib/safe-remove-job.js'; import { logger } from '../../logger.js'; import { scheduleQueue } from '../../queues.js'; import type { BotContext } from '../../types.js'; @@ -36,7 +45,7 @@ export type ScheduleCreateInput = AnnouncementInput & { cron?: string | null; }; -function parseScheduleWhen(input: string): Date { +function parseScheduleWhen(input: string, timeZone: string): Date { const trimmed = input.trim(); if (/^\d+[smhd]$/i.test(trimmed)) { const ms = parseDuration(trimmed); @@ -46,11 +55,27 @@ function parseScheduleWhen(input: string): Date { return new Date(Date.now() + ms); } - const parsed = Date.parse(trimmed); - if (Number.isNaN(parsed) || parsed <= Date.now()) { + try { + const parsed = resolveScheduleRunAt(trimmed, timeZone); + if (parsed.getTime() <= Date.now()) { + throw new SchedulerError('invalid_when'); + } + return parsed; + } catch { throw new SchedulerError('invalid_when'); } - return new Date(parsed); +} + +async function getGuildTimezone(prisma: BotContext['prisma'], guildId: string): Promise { + const settings = await prisma.guildSettings.findUnique({ + where: { guildId }, + select: { timezone: true } + }); + return settings?.timezone || DEFAULT_GUILD_TIMEZONE; +} + +export function scheduleJobId(scheduleId: string): string { + return `schedule-${scheduleId}`; } function isValidCron(pattern: string): boolean { @@ -157,53 +182,74 @@ async function assertSendableChannel( async function enqueueScheduleJob( schedule: ScheduledMessage, cron: string | null, - runAt: Date | null + runAt: Date | null, + timeZone: string ): Promise { - const jobOptions: { - jobId: string; - removeOnComplete: boolean; - removeOnFail: number; - delay?: number; - repeat?: { pattern: string }; - } = { - jobId: `schedule-${schedule.id}`, - removeOnComplete: true, - removeOnFail: 50 - }; + const jobId = scheduleJobId(schedule.id); if (cron) { - jobOptions.repeat = { pattern: cron }; - } else if (runAt) { - jobOptions.delay = Math.max(0, runAt.getTime() - Date.now()); + // BullMQ 5 job schedulers honor guild timezone for cron patterns. + await scheduleQueue.upsertJobScheduler( + jobId, + { pattern: cron, tz: timeZone }, + { + name: 'scheduleSend', + data: { scheduleId: schedule.id }, + opts: { + removeOnComplete: true, + removeOnFail: 50 + } + } + ); + return jobId; } - const job = await scheduleQueue.add('scheduleSend', { scheduleId: schedule.id }, jobOptions); + if (!runAt) { + throw new SchedulerError('missing_schedule_time'); + } + + const job = await scheduleQueue.add( + 'scheduleSend', + { scheduleId: schedule.id }, + { + jobId, + removeOnComplete: true, + removeOnFail: 50, + delay: Math.max(0, runAt.getTime() - Date.now()) + } + ); return String(job.id); } /** - * Removes a scheduled/failed job if present. Locked (active) jobs cannot be - * removed — do not call this from inside the scheduleSend worker for the - * job that is currently running. + * Removes a one-shot delayed job and/or cron job scheduler. Locked (active) + * jobs cannot be removed — do not call this from inside the scheduleSend + * worker for the job that is currently running. */ -export async function removeScheduleJob(jobId: string | null): Promise { - if (!jobId) { - return; +export async function removeScheduleJob( + jobId: string | null, + options?: { scheduleId?: string; cron?: string | null } +): Promise { + const schedulerId = options?.scheduleId ? scheduleJobId(options.scheduleId) : jobId; + if (schedulerId && options?.cron) { + try { + await scheduleQueue.removeJobScheduler(schedulerId); + } catch (error) { + logger.warn({ schedulerId, error }, 'Failed to remove schedule job scheduler'); + } } - const job = await scheduleQueue.getJob(jobId); - if (!job) { - return; + const candidateIds = new Set(); + if (jobId) { + candidateIds.add(jobId); } - try { - await job.remove(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes('locked') || message.includes('could not be removed')) { - logger.warn({ jobId, error }, 'Schedule job is locked; skipping remove'); - return; - } - throw error; + if (schedulerId) { + candidateIds.add(schedulerId); + } + + for (const id of candidateIds) { + const job = await scheduleQueue.getJob(id); + await safeRemoveBullJob(job, { label: 'schedule', jobId: id }); } } @@ -235,6 +281,7 @@ export async function createScheduledMessage( let runAt: Date | null = null; let cronPattern: string | null = null; + const timeZone = await getGuildTimezone(context.prisma, input.guildId); if (cron) { if (!isValidCron(cron)) { @@ -242,7 +289,7 @@ export async function createScheduledMessage( } cronPattern = cron; } else if (whenInput) { - runAt = parseScheduleWhen(whenInput); + runAt = parseScheduleWhen(whenInput, timeZone); } await assertSendableChannel(context, input.channelId); @@ -262,7 +309,7 @@ export async function createScheduledMessage( } }); - const jobId = await enqueueScheduleJob(schedule, cronPattern, runAt); + const jobId = await enqueueScheduleJob(schedule, cronPattern, runAt, timeZone); return context.prisma.scheduledMessage.update({ where: { id: schedule.id }, data: { jobId } @@ -322,7 +369,7 @@ export async function deleteScheduledMessage( return false; } - await removeScheduleJob(match.jobId); + await removeScheduleJob(match.jobId, { scheduleId: match.id, cron: match.cron }); await context.prisma.scheduledMessage.delete({ where: { id: match.id } }); return true; } diff --git a/apps/webui/src/app/globals.css b/apps/webui/src/app/globals.css index fefdfdc..36c9bad 100644 --- a/apps/webui/src/app/globals.css +++ b/apps/webui/src/app/globals.css @@ -3,6 +3,7 @@ @custom-variant dark (&:is(.dark *)); :root { + color-scheme: light; --background: #f8f8fb; --foreground: #111116; --card: #ffffff; @@ -26,6 +27,7 @@ } .dark { + color-scheme: dark; --background: #0a0a0d; --foreground: #e6e6ea; --card: #131318; diff --git a/apps/webui/src/components/modules/scheduler-manager.tsx b/apps/webui/src/components/modules/scheduler-manager.tsx index 1d5eaee..e1e0bb3 100644 --- a/apps/webui/src/components/modules/scheduler-manager.tsx +++ b/apps/webui/src/components/modules/scheduler-manager.tsx @@ -29,10 +29,12 @@ import { Textarea } from '@/components/ui/textarea'; import { cn } from '@/lib/utils'; type ScheduleContentMode = 'text' | 'embed' | 'components_v2'; +type ScheduleTimingMode = 'once' | 'cron'; const EMPTY_DRAFT = { channelId: '', contentMode: 'text' as ScheduleContentMode, + timingMode: 'once' as ScheduleTimingMode, content: '', embed: null as WelcomeEmbed | null, components: null as MessageComponentsV2 | null, @@ -76,6 +78,19 @@ function schedulePreview(schedule: ScheduledMessageDashboard, t: (key: string) = return t('modulePages.scheduler.noContent'); } +function scheduleErrorMessage(code: string | undefined, t: (key: string) => string): string { + if (code === 'invalid_cron') { + return t('modulePages.scheduler.errors.invalidCron'); + } + if (code === 'invalid_when') { + return t('modulePages.scheduler.errors.invalidWhen'); + } + if (code === 'missing_schedule_time') { + return t('modulePages.scheduler.errors.missingScheduleTime'); + } + return code?.trim() ? code : t('common.saveError'); +} + interface SchedulerManagerProps { guildId: string; initialSchedules: ScheduledMessageDashboard[]; @@ -98,7 +113,10 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager (draft.contentMode === 'embed' && (embedHasContent(embed) || embed?.color !== undefined)) || (draft.contentMode === 'components_v2' && componentsV2HasContent(components)); - if (!draft.channelId.trim() || !hasContent || (!draft.cron.trim() && !draft.runAt)) { + const cron = draft.timingMode === 'cron' ? draft.cron.trim() : ''; + const runAt = draft.timingMode === 'once' ? draft.runAt.trim() : ''; + + if (!draft.channelId.trim() || !hasContent || (!cron && !runAt)) { toast.error(t('modulePages.scheduler.formIncomplete')); return; } @@ -114,15 +132,16 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager embed, components, rolePingId: draft.rolePingId.trim() || undefined, - cron: draft.cron.trim() || null, - runAt: draft.cron.trim() ? null : draft.runAt ? new Date(draft.runAt).toISOString() : null + // Send naive datetime-local; server interprets it in the guild timezone. + cron: cron || null, + runAt: cron ? null : runAt || null }) }); const body = (await response.json().catch(() => null)) as | (ScheduledMessageDashboard & { error?: string }) | null; if (!response.ok || !body) { - toast.error(body?.error ?? t('common.saveError')); + toast.error(scheduleErrorMessage(body?.error, t)); return; } setSchedules((prev) => [...prev, body]); @@ -179,24 +198,55 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager placeholder={t('common.optional')} /> + + +
+ +
+ {(['once', 'cron'] as ScheduleTimingMode[]).map((mode) => ( + + ))} +
+
+ + {draft.timingMode === 'cron' ? (
- + setDraft((prev) => ({ ...prev, cron: event.target.value }))} placeholder="0 9 * * *" + autoComplete="off" />
+ ) : (
- + setDraft((prev) => ({ ...prev, runAt: event.target.value }))} />
- + )}
diff --git a/apps/webui/src/components/ui/input.tsx b/apps/webui/src/components/ui/input.tsx index 06f5ec3..c1062b6 100644 --- a/apps/webui/src/components/ui/input.tsx +++ b/apps/webui/src/components/ui/input.tsx @@ -9,7 +9,7 @@ const Input = React.forwardRef(({ className, type, type={type} data-slot="input" className={cn( - 'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50', + 'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50', className )} ref={ref} diff --git a/apps/webui/src/lib/module-configs/scheduler.ts b/apps/webui/src/lib/module-configs/scheduler.ts index e824935..0ba9ed9 100644 --- a/apps/webui/src/lib/module-configs/scheduler.ts +++ b/apps/webui/src/lib/module-configs/scheduler.ts @@ -1,6 +1,8 @@ import { + DEFAULT_GUILD_TIMEZONE, MessageComponentsV2Schema, WelcomeEmbedSchema, + resolveScheduleRunAt, type MessageComponentsV2, type ScheduledMessageDashboard, type ScheduledMessageDashboardCreate, @@ -42,6 +44,10 @@ function toDashboard(schedule: ScheduledMessage): ScheduledMessageDashboard { }; } +function scheduleJobId(scheduleId: string): string { + return `schedule-${scheduleId}`; +} + export async function listScheduledMessagesDashboard(guildId: string): Promise { const schedules = await prisma.scheduledMessage.findMany({ where: { guildId, enabled: true }, @@ -56,6 +62,14 @@ function isValidCron(pattern: string): boolean { return parts.length >= 5 && parts.length <= 6; } +async function getGuildTimezone(guildId: string): Promise { + const settings = await prisma.guildSettings.findUnique({ + where: { guildId }, + select: { timezone: true } + }); + return settings?.timezone || DEFAULT_GUILD_TIMEZONE; +} + /** * Mirrors `apps/bot/src/modules/scheduler/service.ts#createScheduledMessage` * job scheduling exactly (same queue, same `scheduleSend` job name, same @@ -77,7 +91,15 @@ export async function createScheduledMessageDashboard( throw new SchedulerValidationError('invalid_cron'); } - const runAt = !cron && input.runAt ? new Date(input.runAt) : null; + const timeZone = await getGuildTimezone(guildId); + let runAt: Date | null = null; + if (!cron && input.runAt) { + try { + runAt = resolveScheduleRunAt(input.runAt, timeZone); + } catch { + throw new SchedulerValidationError('invalid_when'); + } + } if (!cron && !runAt) { throw new SchedulerValidationError('missing_schedule_time'); } @@ -102,27 +124,38 @@ export async function createScheduledMessageDashboard( } }); - const jobOptions: { - jobId: string; - removeOnComplete: boolean; - removeOnFail: number; - delay?: number; - repeat?: { pattern: string }; - } = { - jobId: `schedule-${schedule.id}`, - removeOnComplete: true, - removeOnFail: 50 - }; + const jobId = scheduleJobId(schedule.id); + const queue = getScheduleQueue(); + if (cron) { - jobOptions.repeat = { pattern: cron }; + await queue.upsertJobScheduler( + jobId, + { pattern: cron, tz: timeZone }, + { + name: 'scheduleSend', + data: { scheduleId: schedule.id }, + opts: { + removeOnComplete: true, + removeOnFail: 50 + } + } + ); } else if (runAt) { - jobOptions.delay = Math.max(0, runAt.getTime() - Date.now()); + await queue.add( + 'scheduleSend', + { scheduleId: schedule.id }, + { + jobId, + removeOnComplete: true, + removeOnFail: 50, + delay: Math.max(0, runAt.getTime() - Date.now()) + } + ); } - const job = await getScheduleQueue().add('scheduleSend', { scheduleId: schedule.id }, jobOptions); const updated = await prisma.scheduledMessage.update({ where: { id: schedule.id }, - data: { jobId: String(job.id) } + data: { jobId } }); return toDashboard(updated); @@ -134,16 +167,34 @@ export async function deleteScheduledMessageDashboard(guildId: string, scheduleI return false; } + const queue = getScheduleQueue(); + const schedulerId = scheduleJobId(existing.id); + + if (existing.cron) { + try { + await queue.removeJobScheduler(schedulerId); + } catch { + // Scheduler may already be gone (legacy repeatables / one-shots). + } + } + + const candidateIds = new Set(); + candidateIds.add(schedulerId); if (existing.jobId) { - const job = await getScheduleQueue().getJob(existing.jobId); - if (job) { - try { - await job.remove(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (!message.includes('locked') && !message.includes('could not be removed')) { - throw error; - } + candidateIds.add(existing.jobId); + } + + for (const id of candidateIds) { + const job = await queue.getJob(id); + if (!job) { + continue; + } + try { + await job.remove(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes('locked') && !message.includes('could not be removed')) { + throw error; } } } diff --git a/apps/webui/src/messages/de.json b/apps/webui/src/messages/de.json index 499bafb..040a12e 100644 --- a/apps/webui/src/messages/de.json +++ b/apps/webui/src/messages/de.json @@ -933,6 +933,11 @@ "rolePingId": "Zu erwähnende Rolle", "cron": "Cron-Ausdruck", "runAt": "Ausführen am", + "timingMode": "Zeitplan", + "timingModes": { + "once": "Einmalig", + "cron": "Wiederkehrend (Cron)" + }, "content": "Nachrichtentext", "embed": "Embed (optional)", "components": "Components-V2-Layout", @@ -943,10 +948,15 @@ "components_v2": "Components V2" }, "componentsPreview": "(Components V2)", - "cronHint": "Cron-Ausdruck für wiederkehrende Nachrichten ODER ein einmaliges Datum/Uhrzeit — nicht beides. Wähle Text, Embed oder Components V2 (gegenseitig ausgeschlossen).", + "cronHint": "Einmalige Zeit oder Cron-Wiederholung — bezogen auf die Server-Zeitzone unter Allgemeine Einstellungen. Wähle Text, Embed oder Components V2 (gegenseitig ausgeschlossen).", "create": "Nachricht planen", "created": "Nachricht geplant.", "formIncomplete": "Bitte Kanal, Nachrichtenformat mit Inhalt sowie Cron-Ausdruck oder Datum/Uhrzeit ausfüllen.", + "errors": { + "invalidCron": "Ungültiger Cron-Ausdruck (5–6 Felder, z. B. 0 9 * * *).", + "invalidWhen": "Ungültiges Datum/Uhrzeit.", + "missingScheduleTime": "Bitte Cron-Ausdruck oder Datum/Uhrzeit angeben." + }, "confirmDelete": "Diesen Zeitplan wirklich löschen?", "listTitle": "Geplante Nachrichten", "listDescription": "Anstehende und wiederkehrende geplante Nachrichten.", diff --git a/apps/webui/src/messages/en.json b/apps/webui/src/messages/en.json index 16fb14d..4d189c6 100644 --- a/apps/webui/src/messages/en.json +++ b/apps/webui/src/messages/en.json @@ -933,6 +933,11 @@ "rolePingId": "Role to ping", "cron": "Cron expression", "runAt": "Run at", + "timingMode": "Schedule", + "timingModes": { + "once": "One-time", + "cron": "Recurring (cron)" + }, "content": "Message content", "embed": "Embed (optional)", "components": "Components V2 layout", @@ -943,10 +948,15 @@ "components_v2": "Components V2" }, "componentsPreview": "(Components V2)", - "cronHint": "Set a cron expression for recurring messages, or a one-time date/time — not both. Choose text, embed, or Components V2 (mutually exclusive).", + "cronHint": "One-time time or cron recurrence — evaluated in the server timezone under General Settings. Choose text, embed, or Components V2 (mutually exclusive).", "create": "Schedule message", "created": "Message scheduled.", "formIncomplete": "Please fill channel, choose a message format with content, and either a cron expression or a date/time.", + "errors": { + "invalidCron": "Invalid cron expression (5–6 fields, e.g. 0 9 * * *).", + "invalidWhen": "Invalid date/time.", + "missingScheduleTime": "Provide a cron expression or a date/time." + }, "confirmDelete": "Really delete this schedule?", "listTitle": "Scheduled messages", "listDescription": "Upcoming and recurring scheduled messages.", diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index fd01a75..7f8bd89 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -598,3 +598,20 @@ - [ ] Online-Kanal zeigt sinnvolle Zahl (nicht dauerhaft 0) - [ ] Bot ohne „Kanäle verwalten“ → Config gespeichert; Bot-Log warnt; nach Permission-Fix erneut speichern +## Post-Phase – Scheduler Cron / Run-at Fix (Status: implementiert) + +### Abgeschlossen (Code) + +- Cron und einmaliges „Ausführen am“ nutzen die Guild-Zeitzone (`GuildSettings.timezone`) +- Cron über BullMQ `upsertJobScheduler` inkl. `tz`; Delete entfernt Job-Scheduler korrekt +- Dashboard: Timing-Mode (einmalig vs. Cron), naive `datetime-local` an Server, Fehlertexte i18n +- Dark Mode: `color-scheme` + Input `text-foreground` für datetime-local Lesbarkeit +- Shared Helper `resolveScheduleRunAt` / `guildLocalDateTimeToUtc` + +### Manuell testen + +- [ ] WebUI einmalig: Datum/Uhrzeit in Server-Zeitzone → Nachricht zur erwarteten lokalen Zeit +- [ ] WebUI Cron `0 9 * * *` bei Europe/Berlin → ca. 09:00 Berlin (nicht UTC) +- [ ] Cron-Plan löschen → keine weiteren Sends +- [ ] Dark Mode: Cron-Feld und datetime-local lesbar/bedienbar + diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d189ed3..f523ace 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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; diff --git a/packages/shared/src/timezone.test.ts b/packages/shared/src/timezone.test.ts new file mode 100644 index 0000000..ecb43ff --- /dev/null +++ b/packages/shared/src/timezone.test.ts @@ -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'); + }); +}); diff --git a/packages/shared/src/timezone.ts b/packages/shared/src/timezone.ts new file mode 100644 index 0000000..e10f68d --- /dev/null +++ b/packages/shared/src/timezone.ts @@ -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> = {}; + 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; +}