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

@@ -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')}
/>
</div>
</div>
<div className="space-y-2">
<Label>{t('modulePages.scheduler.timingMode')}</Label>
<div className="flex flex-wrap gap-2">
{(['once', 'cron'] as ScheduleTimingMode[]).map((mode) => (
<Button
key={mode}
type="button"
variant={draft.timingMode === mode ? 'default' : 'outline'}
size="sm"
className={cn(draft.timingMode !== mode && 'text-muted-foreground')}
onClick={() =>
setDraft((prev) => ({
...prev,
timingMode: mode,
cron: mode === 'cron' ? prev.cron : '',
runAt: mode === 'once' ? prev.runAt : ''
}))
}
>
{t(`modulePages.scheduler.timingModes.${mode}`)}
</Button>
))}
</div>
</div>
{draft.timingMode === 'cron' ? (
<div className="space-y-2">
<Label>{t('modulePages.scheduler.cron')}</Label>
<Label htmlFor="scheduler-cron">{t('modulePages.scheduler.cron')}</Label>
<Input
id="scheduler-cron"
value={draft.cron}
onChange={(event) => setDraft((prev) => ({ ...prev, cron: event.target.value }))}
placeholder="0 9 * * *"
autoComplete="off"
/>
</div>
) : (
<div className="space-y-2">
<Label>{t('modulePages.scheduler.runAt')}</Label>
<Label htmlFor="scheduler-run-at">{t('modulePages.scheduler.runAt')}</Label>
<Input
id="scheduler-run-at"
type="datetime-local"
value={draft.runAt}
disabled={Boolean(draft.cron.trim())}
onChange={(event) => setDraft((prev) => ({ ...prev, runAt: event.target.value }))}
/>
</div>
</div>
)}
<div className="space-y-2">
<Label>{t('modulePages.scheduler.contentMode')}</Label>

View File

@@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ 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}