From ea5395de1f8dfe1b26bb9dc18946530055323e0d Mon Sep 17 00:00:00 2001 From: ClaireGz Date: Sat, 11 Jul 2026 12:44:28 +0200 Subject: [PATCH 1/5] feat(frontend): friendly automation scheduling instead of raw cron Replace the raw-cron-only escape hatch in the automation schedule trigger with friendly pickers: pick a frequency (hourly, daily, weekdays, weekly, monthly) and a time (HH:MM), day of week, or day of month, and the cron is generated under the hood. An "Advanced (cron)" option preserves raw cron input for power users, with a live human-readable description. Add cron-schedule helpers (build/parse/describe) with unit tests. Times are interpreted in the server timezone, consistent with existing backend behavior and the "server time" labels in the details sidebar. Closes #1130 Co-Authored-By: Claude Opus 4.8 --- .../src/components/automations-form.tsx | 336 ++++++++++++++---- apps/frontend/src/lib/cron-schedule.test.ts | 55 +++ apps/frontend/src/lib/cron-schedule.ts | 153 ++++++++ 3 files changed, 475 insertions(+), 69 deletions(-) create mode 100644 apps/frontend/src/lib/cron-schedule.test.ts create mode 100644 apps/frontend/src/lib/cron-schedule.ts diff --git a/apps/frontend/src/components/automations-form.tsx b/apps/frontend/src/components/automations-form.tsx index 72ef96365..e1fb47339 100644 --- a/apps/frontend/src/components/automations-form.tsx +++ b/apps/frontend/src/components/automations-form.tsx @@ -6,6 +6,7 @@ import type { McpServerStatus } from '@nao/shared'; import type { LlmProvider } from '@nao/shared/types'; import type { FormEvent, ReactNode, RefObject } from 'react'; import type { PromptHandle } from 'prompt-mentions'; +import type { ScheduleConfig, ScheduleFrequency } from '@/lib/cron-schedule'; import McpIcon from '@/components/icons/model-context-protocol.svg'; import SlackIcon from '@/components/icons/slack.svg'; import { ChatPrompt, DATABASE_MENTION_TRIGGER, SKILL_MENTION_TRIGGER } from '@/components/chat-input-prompt'; @@ -26,6 +27,14 @@ import { Input } from '@/components/ui/input'; import { LlmProviderIcon } from '@/components/ui/llm-provider-icon'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { cn } from '@/lib/utils'; +import { + buildScheduleCron, + DAY_OF_WEEK_LABELS, + defaultScheduleConfig, + describeCron, + describeSchedule, + parseScheduleCron, +} from '@/lib/cron-schedule'; import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { useSession } from '@/lib/auth-client'; import { trpc } from '@/main'; @@ -122,14 +131,9 @@ type AutomationDetails = { lastRunAt?: Date | string | null; }; -type ScheduleOption = 'hourly' | 'daily' | 'weekdays' | 'weekly' | 'monthly' | 'custom'; +type ScheduleMode = 'friendly' | 'advanced'; -type SchedulePreset = { - value: Exclude; - label: string; - cron: string; - description: string; -}; +type ScheduleSelectOption = ScheduleFrequency | 'advanced'; type AvailableModel = { provider: LlmProvider; @@ -139,9 +143,6 @@ type AvailableModel = { const defaultModelValue = 'default'; const MODE_MENTION_TRIGGER = '#'; -const DEFAULT_SCHEDULE_CRON = '0 9 * * 1'; -const DEFAULT_SCHEDULE_DESCRIPTION = 'Every Monday at 9am'; -const CUSTOM_SCHEDULE_DESCRIPTION = 'Custom schedule'; const defaultValue: AutomationFormValue = { title: '', @@ -157,14 +158,17 @@ const defaultValue: AutomationFormValue = { webhookEnabled: false, }; -const schedulePresets: SchedulePreset[] = [ - { value: 'hourly', label: 'Hourly', cron: '0 * * * *', description: 'Hourly' }, - { value: 'daily', label: 'Daily', cron: '0 9 * * *', description: 'Daily at 9am' }, - { value: 'weekdays', label: 'Weekdays', cron: '0 9 * * 1-5', description: 'Weekdays at 9am' }, - { value: 'weekly', label: 'Weekly', cron: DEFAULT_SCHEDULE_CRON, description: DEFAULT_SCHEDULE_DESCRIPTION }, - { value: 'monthly', label: 'Monthly', cron: '0 9 1 * *', description: 'Monthly on the 1st at 9am' }, +const scheduleFrequencyOptions: Array<{ value: ScheduleFrequency; label: string }> = [ + { value: 'hourly', label: 'Hourly' }, + { value: 'daily', label: 'Daily' }, + { value: 'weekdays', label: 'Weekdays' }, + { value: 'weekly', label: 'Weekly' }, + { value: 'monthly', label: 'Monthly' }, ]; +const hourlyMinuteOptions = Array.from({ length: 12 }, (_, index) => index * 5); +const dayOfMonthOptions = Array.from({ length: 28 }, (_, index) => index + 1); + export function AutomationForm({ id, automationId, @@ -211,8 +215,10 @@ export function AutomationForm({ (savedValue); - const [scheduleOption, setScheduleOption] = useState(() => inferScheduleOption(savedValue)); + const [scheduleMode, setScheduleMode] = useState(() => inferScheduleMode(savedValue.cron)); const [hasSchedule, setHasSchedule] = useState(() => savedValue.cron.trim().length > 0); const [promptError, setPromptError] = useState(false); const [triggerError, setTriggerError] = useState(false); @@ -329,7 +335,7 @@ function useAutomationFormController({ const nextValue = deserializeAutomationValue(initialValueSnapshot); setSavedValue(nextValue); setValue(nextValue); - setScheduleOption(inferScheduleOption(nextValue)); + setScheduleMode(inferScheduleMode(nextValue.cron)); setHasSchedule(nextValue.cron.trim().length > 0); setPromptError(false); setTriggerError(false); @@ -387,7 +393,7 @@ function useAutomationFormController({ setValue({ ...value, cron, - scheduleDescription: CUSTOM_SCHEDULE_DESCRIPTION, + scheduleDescription: describeCron(cron), }); } @@ -463,17 +469,17 @@ function useAutomationFormController({ function handleAddSchedule() { setTriggerError(false); - setScheduleOption('weekly'); + setScheduleMode('friendly'); setHasSchedule(true); handleControlValueChange({ ...value, - cron: DEFAULT_SCHEDULE_CRON, - scheduleDescription: DEFAULT_SCHEDULE_DESCRIPTION, + cron: buildScheduleCron(defaultScheduleConfig), + scheduleDescription: describeSchedule(defaultScheduleConfig), }); } function handleRemoveSchedule() { - setScheduleOption('custom'); + setScheduleMode('friendly'); setHasSchedule(false); handleControlValueChange({ ...value, @@ -491,22 +497,25 @@ function useAutomationFormController({ handleControlValueChange({ ...value, webhookEnabled: false }); } - function handleScheduleOptionChange(option: ScheduleOption) { + function applyScheduleConfig(config: ScheduleConfig) { setTriggerError(false); - setScheduleOption(option); - if (option === 'custom') { - handleControlValueChange({ ...value, scheduleDescription: CUSTOM_SCHEDULE_DESCRIPTION }); - return; - } - - const preset = getSchedulePreset(option); + setScheduleMode('friendly'); handleControlValueChange({ ...value, - cron: preset.cron, - scheduleDescription: preset.description, + cron: buildScheduleCron(config), + scheduleDescription: describeSchedule(config), }); } + function handleScheduleSelectChange(option: ScheduleSelectOption) { + if (option === 'advanced') { + setScheduleMode('advanced'); + return; + } + const currentConfig = parseScheduleCron(value.cron) ?? defaultScheduleConfig; + applyScheduleConfig({ ...currentConfig, frequency: option }); + } + function handleModelChange(modelValue: string) { if (modelValue === defaultModelValue) { handleControlValueChange({ ...value, modelProvider: undefined, modelId: undefined }); @@ -520,6 +529,7 @@ function useAutomationFormController({ } return { + applyScheduleConfig, availableModels: availableModels.data, clearEmailRecipientsError, controlsDisabled, @@ -533,14 +543,15 @@ function useAutomationFormController({ handlePromptChange, handleRemoveSchedule, handleRemoveWebhook, - handleScheduleOptionChange, + handleScheduleSelectChange, handleSubmit, handleValueChange, hasSchedule, mcpServers: mcpServersQuery.data, promptError, promptRef, - scheduleOption, + scheduleConfig: parseScheduleCron(value.cron) ?? defaultScheduleConfig, + scheduleMode, selectedModelName, selectedModelValue, setCustomCron, @@ -577,8 +588,10 @@ function AutomationTitleField({ function TriggersSection({ cron, hasSchedule, - scheduleOption, - onScheduleOptionChange, + scheduleMode, + scheduleConfig, + onScheduleSelectChange, + onScheduleConfigChange, onCustomCronChange, onAddSchedule, onRemoveSchedule, @@ -591,8 +604,10 @@ function TriggersSection({ }: { cron: string; hasSchedule: boolean; - scheduleOption: ScheduleOption; - onScheduleOptionChange: (option: ScheduleOption) => void; + scheduleMode: ScheduleMode; + scheduleConfig: ScheduleConfig; + onScheduleSelectChange: (option: ScheduleSelectOption) => void; + onScheduleConfigChange: (config: ScheduleConfig) => void; onCustomCronChange: (cron: string) => void; onAddSchedule: () => void; onRemoveSchedule: () => void; @@ -617,8 +632,10 @@ function TriggersSection({ {hasSchedule && ( void; + scheduleMode: ScheduleMode; + scheduleConfig: ScheduleConfig; + onScheduleSelectChange: (option: ScheduleSelectOption) => void; + onScheduleConfigChange: (config: ScheduleConfig) => void; onCustomCronChange: (cron: string) => void; onRemove: () => void; disabled: boolean; }) { + const selectValue: ScheduleSelectOption = scheduleMode === 'advanced' ? 'advanced' : scheduleConfig.frequency; + return (
@@ -666,8 +689,8 @@ function ScheduleTriggerRow({
- {scheduleOption === 'custom' && ( - onCustomCronChange(event.target.value)} - placeholder='0 9 * * 1' - className='h-8' + {scheduleMode === 'advanced' ? ( + + ) : ( + + )} + +

Times run in the server timezone.

+
+ ); +} + +function FriendlyScheduleControls({ + config, + onChange, + disabled, +}: { + config: ScheduleConfig; + onChange: (config: ScheduleConfig) => void; + disabled: boolean; +}) { + if (config.frequency === 'hourly') { + return ( +
+ at minute + onChange({ ...config, minute })} + disabled={disabled} + /> +
+ ); + } + + return ( +
+ {config.frequency === 'weekly' && ( + onChange({ ...config, dayOfWeek })} + disabled={disabled} + /> + )} + {config.frequency === 'monthly' && ( + onChange({ ...config, dayOfMonth })} + disabled={disabled} /> )} + at + onChange({ ...config, hour, minute })} + disabled={disabled} + /> +
+ ); +} + +function TimeField({ + hour, + minute, + onChange, + disabled, +}: { + hour: number; + minute: number; + onChange: (hour: number, minute: number) => void; + disabled: boolean; +}) { + return ( + { + const match = /^(\d{2}):(\d{2})$/.exec(event.target.value); + if (match) { + onChange(Number(match[1]), Number(match[2])); + } + }} + /> + ); +} + +function MinuteSelect({ + minute, + onChange, + disabled, +}: { + minute: number; + onChange: (minute: number) => void; + disabled: boolean; +}) { + const options = hourlyMinuteOptions.includes(minute) + ? hourlyMinuteOptions + : [...hourlyMinuteOptions, minute].sort((left, right) => left - right); + + return ( + + ); +} + +function DayOfWeekSelect({ + dayOfWeek, + onChange, + disabled, +}: { + dayOfWeek: number; + onChange: (dayOfWeek: number) => void; + disabled: boolean; +}) { + return ( + + ); +} + +function DayOfMonthSelect({ + dayOfMonth, + onChange, + disabled, +}: { + dayOfMonth: number; + onChange: (dayOfMonth: number) => void; + disabled: boolean; +}) { + return ( + + ); +} + +function AdvancedScheduleField({ + cron, + onChange, + disabled, +}: { + cron: string; + onChange: (cron: string) => void; + disabled: boolean; +}) { + return ( +
+ onChange(event.target.value)} + placeholder='0 9 * * 1' + className='h-8 font-mono' + disabled={disabled} + aria-label='Cron expression' + /> +

{describeCron(cron)}

); } @@ -1788,19 +1990,15 @@ function mergeValue(value: Partial | undefined): Automation }; } -function getScheduleOption(cron: string): ScheduleOption { - return schedulePresets.find((preset) => preset.cron === cron)?.value ?? 'custom'; -} - -function inferScheduleOption(value: AutomationFormValue): ScheduleOption { - if (!value.cron) { - return 'weekly'; +function inferScheduleMode(cron: string): ScheduleMode { + if (!cron.trim()) { + return 'friendly'; } - return value.scheduleDescription === CUSTOM_SCHEDULE_DESCRIPTION ? 'custom' : getScheduleOption(value.cron); + return parseScheduleCron(cron) ? 'friendly' : 'advanced'; } -function getSchedulePreset(value: Exclude): SchedulePreset { - return schedulePresets.find((preset) => preset.value === value) ?? schedulePresets[0]; +function padTwo(value: number): string { + return value.toString().padStart(2, '0'); } function areAutomationValuesEqual(left: AutomationFormValue, right: AutomationFormValue): boolean { diff --git a/apps/frontend/src/lib/cron-schedule.test.ts b/apps/frontend/src/lib/cron-schedule.test.ts new file mode 100644 index 000000000..dd6527b65 --- /dev/null +++ b/apps/frontend/src/lib/cron-schedule.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { buildScheduleCron, describeCron, parseScheduleCron } from './cron-schedule'; +import type { ScheduleConfig } from './cron-schedule'; + +const base: ScheduleConfig = { frequency: 'daily', minute: 0, hour: 9, dayOfWeek: 1, dayOfMonth: 1 }; + +describe('buildScheduleCron', () => { + it('builds hourly, daily, weekdays, weekly and monthly crons', () => { + expect(buildScheduleCron({ ...base, frequency: 'hourly', minute: 30 })).toBe('30 * * * *'); + expect(buildScheduleCron({ ...base, frequency: 'daily', hour: 9, minute: 0 })).toBe('0 9 * * *'); + expect(buildScheduleCron({ ...base, frequency: 'weekdays', hour: 8, minute: 15 })).toBe('15 8 * * 1-5'); + expect(buildScheduleCron({ ...base, frequency: 'weekly', hour: 8, minute: 0, dayOfWeek: 1 })).toBe('0 8 * * 1'); + expect(buildScheduleCron({ ...base, frequency: 'monthly', hour: 9, minute: 0, dayOfMonth: 1 })).toBe( + '0 9 1 * *', + ); + }); +}); + +describe('parseScheduleCron', () => { + it('round-trips friendly cron expressions', () => { + for (const config of [ + { ...base, frequency: 'hourly', minute: 30 } as const, + { ...base, frequency: 'daily', hour: 14, minute: 45 } as const, + { ...base, frequency: 'weekdays', hour: 6, minute: 0 } as const, + { ...base, frequency: 'weekly', hour: 8, minute: 0, dayOfWeek: 5 } as const, + { ...base, frequency: 'monthly', hour: 9, minute: 0, dayOfMonth: 15 } as const, + ]) { + const parsed = parseScheduleCron(buildScheduleCron(config)); + expect(parsed?.frequency).toBe(config.frequency); + expect(parsed?.minute).toBe(config.minute); + } + }); + + it('normalizes Sunday expressed as 7 to 0', () => { + expect(parseScheduleCron('0 8 * * 7')?.dayOfWeek).toBe(0); + }); + + it('returns null for expressions outside the friendly shapes', () => { + expect(parseScheduleCron('*/5 * * * *')).toBeNull(); + expect(parseScheduleCron('0 9-17 * * *')).toBeNull(); + expect(parseScheduleCron('0 9 * 6 *')).toBeNull(); + expect(parseScheduleCron('not a cron')).toBeNull(); + }); +}); + +describe('describeCron', () => { + it('describes friendly crons and falls back for custom ones', () => { + expect(describeCron('0 9 * * *')).toBe('Daily at 09:00'); + expect(describeCron('0 8 * * 1')).toBe('Every Monday at 08:00'); + expect(describeCron('30 * * * *')).toBe('Hourly at :30'); + expect(describeCron('0 9 1 * *')).toBe('Monthly on the 1st at 09:00'); + expect(describeCron('*/5 * * * *')).toBe('Custom schedule'); + }); +}); diff --git a/apps/frontend/src/lib/cron-schedule.ts b/apps/frontend/src/lib/cron-schedule.ts new file mode 100644 index 000000000..b14dd782e --- /dev/null +++ b/apps/frontend/src/lib/cron-schedule.ts @@ -0,0 +1,153 @@ +export type ScheduleFrequency = 'hourly' | 'daily' | 'weekdays' | 'weekly' | 'monthly'; + +export type ScheduleConfig = { + frequency: ScheduleFrequency; + minute: number; + hour: number; + dayOfWeek: number; + dayOfMonth: number; +}; + +export const DAY_OF_WEEK_LABELS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + +export const defaultScheduleConfig: ScheduleConfig = { + frequency: 'weekly', + minute: 0, + hour: 9, + dayOfWeek: 1, + dayOfMonth: 1, +}; + +export function buildScheduleCron(config: ScheduleConfig): string { + const minute = clamp(config.minute, 0, 59); + const hour = clamp(config.hour, 0, 23); + + switch (config.frequency) { + case 'hourly': + return `${minute} * * * *`; + case 'daily': + return `${minute} ${hour} * * *`; + case 'weekdays': + return `${minute} ${hour} * * 1-5`; + case 'weekly': + return `${minute} ${hour} * * ${clamp(config.dayOfWeek, 0, 6)}`; + case 'monthly': + return `${minute} ${hour} ${clamp(config.dayOfMonth, 1, 28)} * *`; + } +} + +/** + * Parse a cron expression back into a friendly schedule config. Returns null + * when the expression does not match one of the friendly shapes, signalling the + * UI to fall back to the advanced (raw cron) editor. + */ +export function parseScheduleCron(cron: string): ScheduleConfig | null { + const fields = cron.trim().split(/\s+/); + if (fields.length !== 5) { + return null; + } + + const [minuteField, hourField, dayOfMonthField, monthField, dayOfWeekField] = fields; + if (monthField !== '*') { + return null; + } + + const minute = parseNumericField(minuteField, 0, 59); + if (minute === null) { + return null; + } + + if (hourField === '*') { + return dayOfMonthField === '*' && dayOfWeekField === '*' + ? { ...defaultScheduleConfig, frequency: 'hourly', minute } + : null; + } + + const hour = parseNumericField(hourField, 0, 23); + if (hour === null) { + return null; + } + + if (dayOfMonthField === '*' && dayOfWeekField === '*') { + return { ...defaultScheduleConfig, frequency: 'daily', minute, hour }; + } + + if (dayOfMonthField === '*' && dayOfWeekField === '1-5') { + return { ...defaultScheduleConfig, frequency: 'weekdays', minute, hour }; + } + + if (dayOfMonthField === '*') { + const dayOfWeek = parseNumericField(dayOfWeekField, 0, 7); + if (dayOfWeek === null) { + return null; + } + return { ...defaultScheduleConfig, frequency: 'weekly', minute, hour, dayOfWeek: dayOfWeek === 7 ? 0 : dayOfWeek }; + } + + if (dayOfWeekField === '*') { + const dayOfMonth = parseNumericField(dayOfMonthField, 1, 28); + if (dayOfMonth === null) { + return null; + } + return { ...defaultScheduleConfig, frequency: 'monthly', minute, hour, dayOfMonth }; + } + + return null; +} + +export function describeSchedule(config: ScheduleConfig): string { + const time = formatTime(config.hour, config.minute); + + switch (config.frequency) { + case 'hourly': + return `Hourly at :${padTwo(config.minute)}`; + case 'daily': + return `Daily at ${time}`; + case 'weekdays': + return `Weekdays at ${time}`; + case 'weekly': + return `Every ${DAY_OF_WEEK_LABELS[config.dayOfWeek] ?? 'day'} at ${time}`; + case 'monthly': + return `Monthly on the ${formatOrdinal(config.dayOfMonth)} at ${time}`; + } +} + +export function describeCron(cron: string): string { + const config = parseScheduleCron(cron); + return config ? describeSchedule(config) : 'Custom schedule'; +} + +export function formatTime(hour: number, minute: number): string { + return `${padTwo(hour)}:${padTwo(minute)}`; +} + +function parseNumericField(field: string, min: number, max: number): number | null { + if (!/^\d+$/.test(field)) { + return null; + } + const value = Number(field); + return value >= min && value <= max ? value : null; +} + +function formatOrdinal(day: number): string { + const remainderTen = day % 10; + const remainderHundred = day % 100; + if (remainderTen === 1 && remainderHundred !== 11) { + return `${day}st`; + } + if (remainderTen === 2 && remainderHundred !== 12) { + return `${day}nd`; + } + if (remainderTen === 3 && remainderHundred !== 13) { + return `${day}rd`; + } + return `${day}th`; +} + +function padTwo(value: number): string { + return value.toString().padStart(2, '0'); +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} From c6b42bd98915a70259be6182dae2b8c3f8580008 Mon Sep 17 00:00:00 2001 From: ClaireGz Date: Sat, 11 Jul 2026 12:59:30 +0200 Subject: [PATCH 2/5] style(frontend): apply prettier formatting to automation schedule files Co-Authored-By: Claude Opus 4.8 --- .../src/components/automations-form.tsx | 6 ++++- apps/frontend/src/lib/cron-schedule.ts | 8 ++++++- apps/frontend/src/routeTree.gen.ts | 24 +++++++++---------- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/apps/frontend/src/components/automations-form.tsx b/apps/frontend/src/components/automations-form.tsx index e1fb47339..bd26a812c 100644 --- a/apps/frontend/src/components/automations-form.tsx +++ b/apps/frontend/src/components/automations-form.tsx @@ -724,7 +724,11 @@ function ScheduleTriggerRow({ {scheduleMode === 'advanced' ? ( ) : ( - + )}

Times run in the server timezone.

diff --git a/apps/frontend/src/lib/cron-schedule.ts b/apps/frontend/src/lib/cron-schedule.ts index b14dd782e..b2ea8cd03 100644 --- a/apps/frontend/src/lib/cron-schedule.ts +++ b/apps/frontend/src/lib/cron-schedule.ts @@ -81,7 +81,13 @@ export function parseScheduleCron(cron: string): ScheduleConfig | null { if (dayOfWeek === null) { return null; } - return { ...defaultScheduleConfig, frequency: 'weekly', minute, hour, dayOfWeek: dayOfWeek === 7 ? 0 : dayOfWeek }; + return { + ...defaultScheduleConfig, + frequency: 'weekly', + minute, + hour, + dayOfWeek: dayOfWeek === 7 ? 0 : dayOfWeek, + }; } if (dayOfWeekField === '*') { diff --git a/apps/frontend/src/routeTree.gen.ts b/apps/frontend/src/routeTree.gen.ts index 15ea7f420..9b0ea4762 100644 --- a/apps/frontend/src/routeTree.gen.ts +++ b/apps/frontend/src/routeTree.gen.ts @@ -306,7 +306,6 @@ const SidebarLayoutStoriesPreviewChatIdStorySlugRoute = } as any) export interface FileRoutesByFullPath { - '/': typeof SidebarLayoutChatLayoutIndexRoute '/consent': typeof ConsentRoute '/embed': typeof EmbedRouteWithChildren '/forgot-password': typeof ForgotPasswordRoute @@ -331,9 +330,10 @@ export interface FileRoutesByFullPath { '/shared-chat/$shareId': typeof SidebarLayoutSharedChatShareIdRoute '/embed/chart/$chartEmbedId': typeof EmbedChartChartEmbedIdRoute '/embed/story/$storyId': typeof EmbedStoryStoryIdRoute - '/feed/': typeof SidebarLayoutFeedIndexRoute + '/': typeof SidebarLayoutChatLayoutIndexRoute + '/feed': typeof SidebarLayoutFeedIndexRoute '/settings/': typeof SidebarLayoutSettingsIndexRoute - '/stories/': typeof SidebarLayoutStoriesIndexRoute + '/stories': typeof SidebarLayoutStoriesIndexRoute '/settings/project/agent': typeof SidebarLayoutSettingsProjectAgentRoute '/settings/project/budgets': typeof SidebarLayoutSettingsProjectBudgetsRoute '/settings/project/mcp-endpoint': typeof SidebarLayoutSettingsProjectMcpEndpointRoute @@ -350,7 +350,6 @@ export interface FileRoutesByFullPath { '/stories/preview/$chatId/$storySlug': typeof SidebarLayoutStoriesPreviewChatIdStorySlugRoute } export interface FileRoutesByTo { - '/': typeof SidebarLayoutChatLayoutIndexRoute '/consent': typeof ConsentRoute '/embed': typeof EmbedRouteWithChildren '/forgot-password': typeof ForgotPasswordRoute @@ -373,6 +372,7 @@ export interface FileRoutesByTo { '/shared-chat/$shareId': typeof SidebarLayoutSharedChatShareIdRoute '/embed/chart/$chartEmbedId': typeof EmbedChartChartEmbedIdRoute '/embed/story/$storyId': typeof EmbedStoryStoryIdRoute + '/': typeof SidebarLayoutChatLayoutIndexRoute '/feed': typeof SidebarLayoutFeedIndexRoute '/settings': typeof SidebarLayoutSettingsIndexRoute '/stories': typeof SidebarLayoutStoriesIndexRoute @@ -441,7 +441,6 @@ export interface FileRoutesById { export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: - | '/' | '/consent' | '/embed' | '/forgot-password' @@ -466,9 +465,10 @@ export interface FileRouteTypes { | '/shared-chat/$shareId' | '/embed/chart/$chartEmbedId' | '/embed/story/$storyId' - | '/feed/' + | '/' + | '/feed' | '/settings/' - | '/stories/' + | '/stories' | '/settings/project/agent' | '/settings/project/budgets' | '/settings/project/mcp-endpoint' @@ -485,7 +485,6 @@ export interface FileRouteTypes { | '/stories/preview/$chatId/$storySlug' fileRoutesByTo: FileRoutesByTo to: - | '/' | '/consent' | '/embed' | '/forgot-password' @@ -508,6 +507,7 @@ export interface FileRouteTypes { | '/shared-chat/$shareId' | '/embed/chart/$chartEmbedId' | '/embed/story/$storyId' + | '/' | '/feed' | '/settings' | '/stories' @@ -630,7 +630,7 @@ declare module '@tanstack/react-router' { '/_sidebar-layout': { id: '/_sidebar-layout' path: '' - fullPath: '/' + fullPath: '' preLoaderRoute: typeof SidebarLayoutRouteImport parentRoute: typeof rootRouteImport } @@ -644,14 +644,14 @@ declare module '@tanstack/react-router' { '/_sidebar-layout/_chat-layout': { id: '/_sidebar-layout/_chat-layout' path: '' - fullPath: '/' + fullPath: '' preLoaderRoute: typeof SidebarLayoutChatLayoutRouteImport parentRoute: typeof SidebarLayoutRoute } '/_sidebar-layout/stories/': { id: '/_sidebar-layout/stories/' path: '/stories' - fullPath: '/stories/' + fullPath: '/stories' preLoaderRoute: typeof SidebarLayoutStoriesIndexRouteImport parentRoute: typeof SidebarLayoutRoute } @@ -665,7 +665,7 @@ declare module '@tanstack/react-router' { '/_sidebar-layout/feed/': { id: '/_sidebar-layout/feed/' path: '/feed' - fullPath: '/feed/' + fullPath: '/feed' preLoaderRoute: typeof SidebarLayoutFeedIndexRouteImport parentRoute: typeof SidebarLayoutRoute } From 2ee7e4c0b4f51ce05ddec858e7e24be341619ca8 Mon Sep 17 00:00:00 2001 From: ClaireGz Date: Sat, 11 Jul 2026 13:30:51 +0200 Subject: [PATCH 3/5] refactor(frontend): lay out schedule trigger as one inline expression Move the frequency select inline right after the "On schedule" label so the granularity and its time/day inputs read left-to-right as a single phrase (e.g. "On schedule [Daily] at [09:00]"), with the delete icon pinned right. Frequency-specific controls flow inline via fragments in a wrapping flex row. Layout only; cron generation is unchanged. Co-Authored-By: Claude Opus 4.8 --- .../src/components/automations-form.tsx | 151 ++++++++++-------- 1 file changed, 81 insertions(+), 70 deletions(-) diff --git a/apps/frontend/src/components/automations-form.tsx b/apps/frontend/src/components/automations-form.tsx index bd26a812c..ea9ea67ce 100644 --- a/apps/frontend/src/components/automations-form.tsx +++ b/apps/frontend/src/components/automations-form.tsx @@ -682,60 +682,68 @@ function ScheduleTriggerRow({ return (
-
-
+
+
On schedule -
-
- - + /> + {scheduleMode === 'advanced' ? ( + + ) : ( + + )}
-
- - {scheduleMode === 'advanced' ? ( - - ) : ( - - )} + aria-label='Remove schedule trigger' + className='inline-flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50' + > + + +
+ {scheduleMode === 'advanced' &&

{describeCron(cron)}

}

Times run in the server timezone.

); } +function ScheduleFrequencySelect({ + value, + onChange, + disabled, +}: { + value: ScheduleSelectOption; + onChange: (option: ScheduleSelectOption) => void; + disabled: boolean; +}) { + return ( + + ); +} + function FriendlyScheduleControls({ config, onChange, @@ -747,32 +755,38 @@ function FriendlyScheduleControls({ }) { if (config.frequency === 'hourly') { return ( -
+ <> at minute onChange({ ...config, minute })} disabled={disabled} /> -
+ ); } return ( -
+ <> {config.frequency === 'weekly' && ( - onChange({ ...config, dayOfWeek })} - disabled={disabled} - /> + <> + on + onChange({ ...config, dayOfWeek })} + disabled={disabled} + /> + )} {config.frequency === 'monthly' && ( - onChange({ ...config, dayOfMonth })} - disabled={disabled} - /> + <> + on day + onChange({ ...config, dayOfMonth })} + disabled={disabled} + /> + )} at onChange({ ...config, hour, minute })} disabled={disabled} /> -
+ ); } @@ -878,13 +892,13 @@ function DayOfMonthSelect({ }) { return ( onChange(event.target.value)} - placeholder='0 9 * * 1' - className='h-8 font-mono' - disabled={disabled} - aria-label='Cron expression' - /> -

{describeCron(cron)}

-
+ onChange(event.target.value)} + placeholder='0 9 * * 1' + className='h-8 w-44 font-mono' + disabled={disabled} + aria-label='Cron expression' + /> ); } From 914266c1cd85707b2e0aff3be2967b7722c7762a Mon Sep 17 00:00:00 2001 From: ClaireGz Date: Sun, 12 Jul 2026 15:47:12 +0200 Subject: [PATCH 4/5] fix(frontend): allow monthly schedules on days 29-31 Extend the friendly monthly day-of-month picker to cover all valid cron day-of-month values (1-31) and widen the build/parse clamps so schedules on the 29th-31st round-trip and stay in the friendly UI instead of falling back to Advanced. Strengthen the cron round-trip test to assert every relevant field per frequency and add a day-31 monthly case. Co-Authored-By: Claude Opus 4.8 --- .../src/components/automations-form.tsx | 2 +- apps/frontend/src/lib/cron-schedule.test.ts | 34 +++++++++++++------ apps/frontend/src/lib/cron-schedule.ts | 4 +-- 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/apps/frontend/src/components/automations-form.tsx b/apps/frontend/src/components/automations-form.tsx index ea9ea67ce..025bbae66 100644 --- a/apps/frontend/src/components/automations-form.tsx +++ b/apps/frontend/src/components/automations-form.tsx @@ -167,7 +167,7 @@ const scheduleFrequencyOptions: Array<{ value: ScheduleFrequency; label: string ]; const hourlyMinuteOptions = Array.from({ length: 12 }, (_, index) => index * 5); -const dayOfMonthOptions = Array.from({ length: 28 }, (_, index) => index + 1); +const dayOfMonthOptions = Array.from({ length: 31 }, (_, index) => index + 1); export function AutomationForm({ id, diff --git a/apps/frontend/src/lib/cron-schedule.test.ts b/apps/frontend/src/lib/cron-schedule.test.ts index dd6527b65..9053559f7 100644 --- a/apps/frontend/src/lib/cron-schedule.test.ts +++ b/apps/frontend/src/lib/cron-schedule.test.ts @@ -18,17 +18,31 @@ describe('buildScheduleCron', () => { }); describe('parseScheduleCron', () => { - it('round-trips friendly cron expressions', () => { - for (const config of [ - { ...base, frequency: 'hourly', minute: 30 } as const, - { ...base, frequency: 'daily', hour: 14, minute: 45 } as const, - { ...base, frequency: 'weekdays', hour: 6, minute: 0 } as const, - { ...base, frequency: 'weekly', hour: 8, minute: 0, dayOfWeek: 5 } as const, - { ...base, frequency: 'monthly', hour: 9, minute: 0, dayOfMonth: 15 } as const, - ]) { + it('round-trips friendly cron expressions across every relevant field', () => { + const cases: Array<{ config: ScheduleConfig; fields: Array }> = [ + { config: { ...base, frequency: 'hourly', minute: 30 }, fields: ['frequency', 'minute'] }, + { config: { ...base, frequency: 'daily', hour: 14, minute: 45 }, fields: ['frequency', 'hour', 'minute'] }, + { config: { ...base, frequency: 'weekdays', hour: 6, minute: 0 }, fields: ['frequency', 'hour', 'minute'] }, + { + config: { ...base, frequency: 'weekly', hour: 8, minute: 0, dayOfWeek: 5 }, + fields: ['frequency', 'hour', 'minute', 'dayOfWeek'], + }, + { + config: { ...base, frequency: 'monthly', hour: 9, minute: 0, dayOfMonth: 15 }, + fields: ['frequency', 'hour', 'minute', 'dayOfMonth'], + }, + { + config: { ...base, frequency: 'monthly', hour: 23, minute: 5, dayOfMonth: 31 }, + fields: ['frequency', 'hour', 'minute', 'dayOfMonth'], + }, + ]; + + for (const { config, fields } of cases) { const parsed = parseScheduleCron(buildScheduleCron(config)); - expect(parsed?.frequency).toBe(config.frequency); - expect(parsed?.minute).toBe(config.minute); + expect(parsed).not.toBeNull(); + for (const field of fields) { + expect(parsed?.[field]).toBe(config[field]); + } } }); diff --git a/apps/frontend/src/lib/cron-schedule.ts b/apps/frontend/src/lib/cron-schedule.ts index b2ea8cd03..820f1df11 100644 --- a/apps/frontend/src/lib/cron-schedule.ts +++ b/apps/frontend/src/lib/cron-schedule.ts @@ -32,7 +32,7 @@ export function buildScheduleCron(config: ScheduleConfig): string { case 'weekly': return `${minute} ${hour} * * ${clamp(config.dayOfWeek, 0, 6)}`; case 'monthly': - return `${minute} ${hour} ${clamp(config.dayOfMonth, 1, 28)} * *`; + return `${minute} ${hour} ${clamp(config.dayOfMonth, 1, 31)} * *`; } } @@ -91,7 +91,7 @@ export function parseScheduleCron(cron: string): ScheduleConfig | null { } if (dayOfWeekField === '*') { - const dayOfMonth = parseNumericField(dayOfMonthField, 1, 28); + const dayOfMonth = parseNumericField(dayOfMonthField, 1, 31); if (dayOfMonth === null) { return null; } From 63a5c544b936b23df4ae61fd9babbc8ed119cb4b Mon Sep 17 00:00:00 2001 From: ClaireGz Date: Sun, 12 Jul 2026 22:25:08 +0200 Subject: [PATCH 5/5] feat(frontend): weekly multi-day picker and free hourly minute input Weekly schedules now accept any combination of days via an inline toggle-button group (Mon-Sun), emitting a sorted comma-separated cron day list (e.g. 0 8 * * 1,3,5). Single-day and Sunday (0/7) still round-trip, and the explicit Weekdays 1-5 preset is preserved; at least one day stays selected so the day field is never empty. Hourly now uses a free numeric minute input (0-59) instead of a 5-minute select, so any minute round-trips. Extend cron-schedule tests: weekly multi-day and single-day round-trips, 1-5 vs comma-list mapping, and a non-multiple-of-5 hourly minute. Co-Authored-By: Claude Opus 4.8 --- .../src/components/automations-form.tsx | 105 +++++++++++------- apps/frontend/src/lib/cron-schedule.test.ts | 35 ++++-- apps/frontend/src/lib/cron-schedule.ts | 57 +++++++--- 3 files changed, 138 insertions(+), 59 deletions(-) diff --git a/apps/frontend/src/components/automations-form.tsx b/apps/frontend/src/components/automations-form.tsx index 025bbae66..4a595ceac 100644 --- a/apps/frontend/src/components/automations-form.tsx +++ b/apps/frontend/src/components/automations-form.tsx @@ -166,8 +166,11 @@ const scheduleFrequencyOptions: Array<{ value: ScheduleFrequency; label: string { value: 'monthly', label: 'Monthly' }, ]; -const hourlyMinuteOptions = Array.from({ length: 12 }, (_, index) => index * 5); const dayOfMonthOptions = Array.from({ length: 31 }, (_, index) => index + 1); +const weekdayToggleOptions: Array<{ value: number; label: string }> = [1, 2, 3, 4, 5, 6, 0].map((value) => ({ + value, + label: DAY_OF_WEEK_LABELS[value].charAt(0), +})); export function AutomationForm({ id, @@ -757,7 +760,7 @@ function FriendlyScheduleControls({ return ( <> at minute - onChange({ ...config, minute })} disabled={disabled} @@ -771,9 +774,9 @@ function FriendlyScheduleControls({ {config.frequency === 'weekly' && ( <> on - onChange({ ...config, dayOfWeek })} + onChange({ ...config, daysOfWeek })} disabled={disabled} /> @@ -827,7 +830,7 @@ function TimeField({ ); } -function MinuteSelect({ +function MinuteInput({ minute, onChange, disabled, @@ -836,48 +839,74 @@ function MinuteSelect({ onChange: (minute: number) => void; disabled: boolean; }) { - const options = hourlyMinuteOptions.includes(minute) - ? hourlyMinuteOptions - : [...hourlyMinuteOptions, minute].sort((left, right) => left - right); - return ( - + { + const parsed = Number.parseInt(event.target.value, 10); + if (!Number.isNaN(parsed)) { + onChange(Math.min(Math.max(parsed, 0), 59)); + } + }} + /> ); } -function DayOfWeekSelect({ - dayOfWeek, +function DaysOfWeekToggle({ + daysOfWeek, onChange, disabled, }: { - dayOfWeek: number; - onChange: (dayOfWeek: number) => void; + daysOfWeek: number[]; + onChange: (daysOfWeek: number[]) => void; disabled: boolean; }) { + const selected = new Set(daysOfWeek); + + function toggleDay(day: number) { + const next = new Set(selected); + if (next.has(day)) { + if (next.size === 1) { + return; + } + next.delete(day); + } else { + next.add(day); + } + onChange([...next].sort((left, right) => left - right)); + } + return ( - +
+ {weekdayToggleOptions.map((option) => { + const isSelected = selected.has(option.value); + return ( + + ); + })} +
); } diff --git a/apps/frontend/src/lib/cron-schedule.test.ts b/apps/frontend/src/lib/cron-schedule.test.ts index 9053559f7..d99e2984c 100644 --- a/apps/frontend/src/lib/cron-schedule.test.ts +++ b/apps/frontend/src/lib/cron-schedule.test.ts @@ -3,14 +3,19 @@ import { describe, expect, it } from 'vitest'; import { buildScheduleCron, describeCron, parseScheduleCron } from './cron-schedule'; import type { ScheduleConfig } from './cron-schedule'; -const base: ScheduleConfig = { frequency: 'daily', minute: 0, hour: 9, dayOfWeek: 1, dayOfMonth: 1 }; +const base: ScheduleConfig = { frequency: 'daily', minute: 0, hour: 9, daysOfWeek: [1], dayOfMonth: 1 }; describe('buildScheduleCron', () => { it('builds hourly, daily, weekdays, weekly and monthly crons', () => { expect(buildScheduleCron({ ...base, frequency: 'hourly', minute: 30 })).toBe('30 * * * *'); expect(buildScheduleCron({ ...base, frequency: 'daily', hour: 9, minute: 0 })).toBe('0 9 * * *'); expect(buildScheduleCron({ ...base, frequency: 'weekdays', hour: 8, minute: 15 })).toBe('15 8 * * 1-5'); - expect(buildScheduleCron({ ...base, frequency: 'weekly', hour: 8, minute: 0, dayOfWeek: 1 })).toBe('0 8 * * 1'); + expect(buildScheduleCron({ ...base, frequency: 'weekly', hour: 8, minute: 0, daysOfWeek: [1] })).toBe( + '0 8 * * 1', + ); + expect(buildScheduleCron({ ...base, frequency: 'weekly', hour: 8, minute: 0, daysOfWeek: [5, 1, 3] })).toBe( + '0 8 * * 1,3,5', + ); expect(buildScheduleCron({ ...base, frequency: 'monthly', hour: 9, minute: 0, dayOfMonth: 1 })).toBe( '0 9 1 * *', ); @@ -20,12 +25,16 @@ describe('buildScheduleCron', () => { describe('parseScheduleCron', () => { it('round-trips friendly cron expressions across every relevant field', () => { const cases: Array<{ config: ScheduleConfig; fields: Array }> = [ - { config: { ...base, frequency: 'hourly', minute: 30 }, fields: ['frequency', 'minute'] }, + { config: { ...base, frequency: 'hourly', minute: 37 }, fields: ['frequency', 'minute'] }, { config: { ...base, frequency: 'daily', hour: 14, minute: 45 }, fields: ['frequency', 'hour', 'minute'] }, { config: { ...base, frequency: 'weekdays', hour: 6, minute: 0 }, fields: ['frequency', 'hour', 'minute'] }, { - config: { ...base, frequency: 'weekly', hour: 8, minute: 0, dayOfWeek: 5 }, - fields: ['frequency', 'hour', 'minute', 'dayOfWeek'], + config: { ...base, frequency: 'weekly', hour: 8, minute: 0, daysOfWeek: [5] }, + fields: ['frequency', 'hour', 'minute', 'daysOfWeek'], + }, + { + config: { ...base, frequency: 'weekly', hour: 8, minute: 0, daysOfWeek: [1, 3, 5] }, + fields: ['frequency', 'hour', 'minute', 'daysOfWeek'], }, { config: { ...base, frequency: 'monthly', hour: 9, minute: 0, dayOfMonth: 15 }, @@ -41,13 +50,22 @@ describe('parseScheduleCron', () => { const parsed = parseScheduleCron(buildScheduleCron(config)); expect(parsed).not.toBeNull(); for (const field of fields) { - expect(parsed?.[field]).toBe(config[field]); + expect(parsed?.[field]).toEqual(config[field]); } } }); + it('parses a weekly multi-day list', () => { + expect(parseScheduleCron('0 8 * * 1,3,5')?.daysOfWeek).toEqual([1, 3, 5]); + }); + + it('keeps 1-5 as Weekdays but comma lists as Weekly', () => { + expect(parseScheduleCron('0 8 * * 1-5')?.frequency).toBe('weekdays'); + expect(parseScheduleCron('0 8 * * 1,2,3,4,5')?.frequency).toBe('weekly'); + }); + it('normalizes Sunday expressed as 7 to 0', () => { - expect(parseScheduleCron('0 8 * * 7')?.dayOfWeek).toBe(0); + expect(parseScheduleCron('0 8 * * 7')?.daysOfWeek).toEqual([0]); }); it('returns null for expressions outside the friendly shapes', () => { @@ -62,7 +80,8 @@ describe('describeCron', () => { it('describes friendly crons and falls back for custom ones', () => { expect(describeCron('0 9 * * *')).toBe('Daily at 09:00'); expect(describeCron('0 8 * * 1')).toBe('Every Monday at 08:00'); - expect(describeCron('30 * * * *')).toBe('Hourly at :30'); + expect(describeCron('0 8 * * 1,3,5')).toBe('Every Mon, Wed, Fri at 08:00'); + expect(describeCron('37 * * * *')).toBe('Hourly at :37'); expect(describeCron('0 9 1 * *')).toBe('Monthly on the 1st at 09:00'); expect(describeCron('*/5 * * * *')).toBe('Custom schedule'); }); diff --git a/apps/frontend/src/lib/cron-schedule.ts b/apps/frontend/src/lib/cron-schedule.ts index 820f1df11..0dadde7a8 100644 --- a/apps/frontend/src/lib/cron-schedule.ts +++ b/apps/frontend/src/lib/cron-schedule.ts @@ -4,7 +4,7 @@ export type ScheduleConfig = { frequency: ScheduleFrequency; minute: number; hour: number; - dayOfWeek: number; + daysOfWeek: number[]; dayOfMonth: number; }; @@ -14,7 +14,7 @@ export const defaultScheduleConfig: ScheduleConfig = { frequency: 'weekly', minute: 0, hour: 9, - dayOfWeek: 1, + daysOfWeek: [1], dayOfMonth: 1, }; @@ -30,7 +30,7 @@ export function buildScheduleCron(config: ScheduleConfig): string { case 'weekdays': return `${minute} ${hour} * * 1-5`; case 'weekly': - return `${minute} ${hour} * * ${clamp(config.dayOfWeek, 0, 6)}`; + return `${minute} ${hour} * * ${formatDaysOfWeek(config.daysOfWeek)}`; case 'monthly': return `${minute} ${hour} ${clamp(config.dayOfMonth, 1, 31)} * *`; } @@ -77,17 +77,11 @@ export function parseScheduleCron(cron: string): ScheduleConfig | null { } if (dayOfMonthField === '*') { - const dayOfWeek = parseNumericField(dayOfWeekField, 0, 7); - if (dayOfWeek === null) { + const daysOfWeek = parseDaysOfWeek(dayOfWeekField); + if (daysOfWeek === null) { return null; } - return { - ...defaultScheduleConfig, - frequency: 'weekly', - minute, - hour, - dayOfWeek: dayOfWeek === 7 ? 0 : dayOfWeek, - }; + return { ...defaultScheduleConfig, frequency: 'weekly', minute, hour, daysOfWeek }; } if (dayOfWeekField === '*') { @@ -112,7 +106,7 @@ export function describeSchedule(config: ScheduleConfig): string { case 'weekdays': return `Weekdays at ${time}`; case 'weekly': - return `Every ${DAY_OF_WEEK_LABELS[config.dayOfWeek] ?? 'day'} at ${time}`; + return `${describeDaysOfWeek(config.daysOfWeek)} at ${time}`; case 'monthly': return `Monthly on the ${formatOrdinal(config.dayOfMonth)} at ${time}`; } @@ -127,6 +121,43 @@ export function formatTime(hour: number, minute: number): string { return `${padTwo(hour)}:${padTwo(minute)}`; } +export function normalizeDaysOfWeek(days: number[]): number[] { + const unique = new Set(days.map((day) => (day === 7 ? 0 : day)).filter((day) => day >= 0 && day <= 6)); + return [...unique].sort((left, right) => left - right); +} + +function formatDaysOfWeek(days: number[]): string { + const normalized = normalizeDaysOfWeek(days); + return (normalized.length > 0 ? normalized : defaultScheduleConfig.daysOfWeek).join(','); +} + +function parseDaysOfWeek(field: string): number[] | null { + const tokens = field.split(','); + const days = new Set(); + for (const token of tokens) { + const value = parseNumericField(token, 0, 7); + if (value === null) { + return null; + } + days.add(value === 7 ? 0 : value); + } + return [...days].sort((left, right) => left - right); +} + +function describeDaysOfWeek(days: number[]): string { + const normalized = normalizeDaysOfWeek(days); + if (normalized.length === 0) { + return 'Weekly'; + } + if (normalized.length === 7) { + return 'Every day'; + } + if (normalized.length === 1) { + return `Every ${DAY_OF_WEEK_LABELS[normalized[0]]}`; + } + return `Every ${normalized.map((day) => DAY_OF_WEEK_LABELS[day].slice(0, 3)).join(', ')}`; +} + function parseNumericField(field: string, min: number, max: number): number | null { if (!/^\d+$/.test(field)) { return null;