diff --git a/packages/twenty-front/package.json b/packages/twenty-front/package.json
index d5b9073b566..de3ce74683b 100644
--- a/packages/twenty-front/package.json
+++ b/packages/twenty-front/package.json
@@ -70,6 +70,7 @@
"apollo-link-rest": "^0.9.0",
"apollo-upload-client": "^17.0.0",
"buffer": "^6.0.3",
+ "cron-parser": "5.1.1",
"date-fns": "^2.30.0",
"docx": "^9.1.0",
"file-saver": "^2.0.5",
diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/CronExpressionHelper.tsx b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/CronExpressionHelper.tsx
new file mode 100644
index 00000000000..5d6471076c1
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/CronExpressionHelper.tsx
@@ -0,0 +1,174 @@
+import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
+import { InputHint } from '@/ui/input/components/InputHint';
+import { InputLabel } from '@/ui/input/components/InputLabel';
+import type { WorkflowCronTrigger } from '@/workflow/types/Workflow';
+import { describeCronExpression } from '@/workflow/workflow-trigger/utils/cron-to-human/describeCronExpression';
+import styled from '@emotion/styled';
+import { t } from '@lingui/core/macro';
+import { CronExpressionParser } from 'cron-parser';
+import { type Locale } from 'date-fns';
+import { useRecoilValue } from 'recoil';
+import { dateLocaleState } from '~/localization/states/dateLocaleState';
+import { formatDateTimeString } from '~/utils/string/formatDateTimeString';
+
+const convertScheduleToCronExpression = (
+ trigger: WorkflowCronTrigger,
+): string | null => {
+ switch (trigger.settings.type) {
+ case 'CUSTOM':
+ return trigger.settings.pattern;
+ case 'DAYS':
+ return `${trigger.settings.schedule.minute} ${trigger.settings.schedule.hour} */${trigger.settings.schedule.day} * *`;
+ case 'HOURS':
+ return `${trigger.settings.schedule.minute} */${trigger.settings.schedule.hour} * * *`;
+ case 'MINUTES':
+ return `*/${trigger.settings.schedule.minute} * * * *`;
+ default:
+ return null;
+ }
+};
+
+const getTriggerScheduleDescription = (
+ trigger: WorkflowCronTrigger,
+ localeCatalog?: Locale,
+): string | null => {
+ const cronExpression = convertScheduleToCronExpression(trigger);
+
+ if (!cronExpression) {
+ return null;
+ }
+
+ try {
+ return describeCronExpression(
+ cronExpression,
+ { use24HourTimeFormat: true },
+ localeCatalog,
+ );
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : t`Invalid cron expression`;
+ return errorMessage;
+ }
+};
+
+const getNextExecutions = (cronExpression: string): Date[] => {
+ try {
+ const interval = CronExpressionParser.parse(cronExpression, {
+ tz: 'UTC',
+ });
+ return interval.take(3).map((date) => date.toDate());
+ } catch {
+ return [];
+ }
+};
+
+const StyledContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${({ theme }) => theme.spacing(3)};
+ margin-top: ${({ theme }) => theme.spacing(2)};
+`;
+
+const StyledSection = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${({ theme }) => theme.spacing(1)};
+`;
+
+const StyledScheduleDescription = styled.div`
+ color: ${({ theme }) => theme.font.color.primary};
+ font-size: ${({ theme }) => theme.font.size.sm};
+ font-weight: ${({ theme }) => theme.font.weight.medium};
+`;
+
+const StyledScheduleSubtext = styled.div`
+ color: ${({ theme }) => theme.font.color.tertiary};
+ font-size: ${({ theme }) => theme.font.size.xs};
+`;
+
+const StyledExecutionItem = styled.div`
+ color: ${({ theme }) => theme.font.color.secondary};
+ font-family: monospace;
+ font-size: ${({ theme }) => theme.font.size.xs};
+ margin-bottom: ${({ theme }) => theme.spacing(0.5)};
+`;
+
+type CronExpressionHelperProps = {
+ trigger: WorkflowCronTrigger;
+ isVisible?: boolean;
+};
+
+export const CronExpressionHelper = ({
+ trigger,
+ isVisible = true,
+}: CronExpressionHelperProps) => {
+ const { timeZone, dateFormat, timeFormat } = useDateTimeFormat();
+ const dateLocale = useRecoilValue(dateLocaleState);
+
+ if (!isVisible) {
+ return null;
+ }
+
+ const cronExpression = convertScheduleToCronExpression(trigger);
+ const customDescription = getTriggerScheduleDescription(
+ trigger,
+ dateLocale.localeCatalog,
+ );
+
+ if (!cronExpression) {
+ return null;
+ }
+
+ let isValid = true;
+ let errorMessage = '';
+
+ try {
+ CronExpressionParser.parse(cronExpression);
+ } catch (error) {
+ isValid = false;
+ errorMessage = error instanceof Error ? error.message : t`Unknown error`;
+ }
+
+ if (!isValid) {
+ return (
+
+
+ {errorMessage || t`Please check your cron expression syntax.`}
+
+
+ );
+ }
+
+ const nextExecutions = getNextExecutions(cronExpression);
+
+ return (
+
+
+ {t`Schedule`}
+
+ {customDescription}
+
+
+ {t`Schedule runs in UTC timezone.`}
+
+
+
+ {nextExecutions.length > 0 && (
+
+ {t`Upcoming execution times (${timeZone})`}
+ {nextExecutions.slice(0, 3).map((execution, index) => (
+
+ {formatDateTimeString({
+ value: execution.toISOString(),
+ timeZone,
+ dateFormat,
+ timeFormat,
+ localeCatalog: dateLocale.localeCatalog,
+ })}
+
+ ))}
+
+ )}
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/CronExpressionHelperLazy.tsx b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/CronExpressionHelperLazy.tsx
new file mode 100644
index 00000000000..eec2c9d2d79
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/CronExpressionHelperLazy.tsx
@@ -0,0 +1,28 @@
+import type { WorkflowCronTrigger } from '@/workflow/types/Workflow';
+import { Suspense, lazy } from 'react';
+
+const CronExpressionHelperComponent = lazy(() =>
+ import('./CronExpressionHelper').then((module) => ({
+ default: module.CronExpressionHelper,
+ })),
+);
+
+type CronExpressionHelperLazyProps = {
+ trigger: WorkflowCronTrigger;
+ isVisible?: boolean;
+};
+
+export const CronExpressionHelperLazy = ({
+ trigger,
+ isVisible = true,
+}: CronExpressionHelperLazyProps) => {
+ if (!isVisible) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm.tsx b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm.tsx
index a75ffaf4023..1b1aa5f28fb 100644
--- a/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm.tsx
+++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerCronForm.tsx
@@ -5,6 +5,7 @@ import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/Gene
import { type WorkflowCronTrigger } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
+import { CronExpressionHelperLazy } from '@/workflow/workflow-trigger/components/CronExpressionHelperLazy';
import { CRON_TRIGGER_INTERVAL_OPTIONS } from '@/workflow/workflow-trigger/constants/CronTriggerIntervalOptions';
import { getCronTriggerDefaultSettings } from '@/workflow/workflow-trigger/utils/getCronTriggerDefaultSettings';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel';
@@ -14,7 +15,6 @@ import { getTriggerIconColor } from '@/workflow/workflow-trigger/utils/getTrigge
import { useTheme } from '@emotion/react';
import { t } from '@lingui/core/macro';
import { isNumber } from '@sniptt/guards';
-import cron from 'cron-validate';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
@@ -108,45 +108,51 @@ export const WorkflowEditTriggerCronForm = ({
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
/>
{trigger.settings.type === 'CUSTOM' && (
- {
- if (triggerOptions.readonly === true) {
- return;
- }
+ <>
+ {
+ if (triggerOptions.readonly === true) {
+ return;
+ }
- const cronValidator = cron(newPattern);
+ const { CronExpressionParser } = await import('cron-parser');
- if (cronValidator.isError() === true) {
- setErrorMessages({
- CUSTOM: `Invalid cron pattern, ${cronValidator
- .getError()[0]
- .replace(/\. \(Input cron:.*$/, '')}`,
+ try {
+ CronExpressionParser.parse(newPattern);
+ } catch (error) {
+ setErrorMessages({
+ CUSTOM: `Invalid cron pattern: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ });
+ return;
+ }
+
+ setErrorMessages((prev) => ({
+ ...prev,
+ CUSTOM: undefined,
+ }));
+
+ triggerOptions.onTriggerUpdate({
+ ...trigger,
+ settings: {
+ ...trigger.settings,
+ type: 'CUSTOM',
+ pattern: newPattern,
+ },
});
- return;
- }
-
- setErrorMessages((prev) => ({
- ...prev,
- CUSTOM: undefined,
- }));
-
- triggerOptions.onTriggerUpdate({
- ...trigger,
- settings: {
- ...trigger.settings,
- type: 'CUSTOM',
- pattern: newPattern,
- },
- });
- }}
- />
+ }}
+ />
+
+ >
)}
{trigger.settings.type === 'DAYS' && (
<>
@@ -196,11 +202,11 @@ export const WorkflowEditTriggerCronForm = ({
},
});
}}
- placeholder="Enter number greater than 1"
+ placeholder={t`Enter number greater than 1`}
readonly={triggerOptions.readonly}
/>
23) {
setErrorMessages((prev) => ({
...prev,
- DAYS_hour: `Invalid hour value '${newHour}'. Should be integer between 0 and 23`,
+ DAYS_hour: t`Invalid hour value '${newHour}'. Should be integer between 0 and 23`,
}));
return;
}
@@ -245,11 +251,11 @@ export const WorkflowEditTriggerCronForm = ({
},
});
}}
- placeholder="Enter number between 0 and 23"
+ placeholder={t`Enter number between 0 and 23`}
readonly={triggerOptions.readonly}
/>
59) {
setErrorMessages((prev) => ({
...prev,
- DAYS_minute: `Invalid minute value '${newMinute}'. Should be integer between 0 and 59`,
+ DAYS_minute: t`Invalid minute value '${newMinute}'. Should be integer between 0 and 59`,
}));
return;
}
@@ -296,9 +302,17 @@ export const WorkflowEditTriggerCronForm = ({
},
});
}}
- placeholder="Enter number between 0 and 59"
+ placeholder={t`Enter number between 0 and 59`}
readonly={triggerOptions.readonly}
/>
+
>
)}
{trigger.settings.type === 'HOURS' && (
@@ -322,7 +336,7 @@ export const WorkflowEditTriggerCronForm = ({
if (!isNumber(newHour) || newHour <= 0) {
setErrorMessages((prev) => ({
...prev,
- HOURS_hour: `Invalid hour value '${newHour}'. Should be integer greater than 1`,
+ HOURS_hour: t`Invalid hour value '${newHour}'. Should be integer greater than 1`,
}));
return;
}
@@ -347,11 +361,11 @@ export const WorkflowEditTriggerCronForm = ({
},
});
}}
- placeholder="Enter number greater than 1"
+ placeholder={t`Enter number greater than 1`}
readonly={triggerOptions.readonly}
/>
59) {
setErrorMessages((prev) => ({
...prev,
- HOURS_minute: `Invalid minute value '${newMinute}'. Should be integer between 0 and 59`,
+ HOURS_minute: t`Invalid minute value '${newMinute}'. Should be integer between 0 and 59`,
}));
return;
}
@@ -394,52 +408,64 @@ export const WorkflowEditTriggerCronForm = ({
},
});
}}
- placeholder="Enter number between 0 and 59"
+ placeholder={t`Enter number between 0 and 59`}
readonly={triggerOptions.readonly}
/>
+
>
)}
{trigger.settings.type === 'MINUTES' && (
- {
- if (triggerOptions.readonly === true) {
- return;
- }
+ <>
+ {
+ if (triggerOptions.readonly === true) {
+ return;
+ }
- if (!isDefined(newMinute)) {
- return;
- }
+ if (!isDefined(newMinute)) {
+ return;
+ }
- if (!isNumber(newMinute) || newMinute <= 0) {
- setErrorMessages({
- MINUTES: `Invalid minute value '${newMinute}'. Should be integer greater than 1`,
- });
- return;
- }
+ if (!isNumber(newMinute) || newMinute <= 0) {
+ setErrorMessages({
+ MINUTES: t`Invalid minute value '${newMinute}'. Should be integer greater than 1`,
+ });
+ return;
+ }
- setErrorMessages((prev) => ({
- ...prev,
- MINUTES: undefined,
- }));
+ setErrorMessages((prev) => ({
+ ...prev,
+ MINUTES: undefined,
+ }));
- triggerOptions.onTriggerUpdate({
- ...trigger,
- settings: {
- ...trigger.settings,
- type: 'MINUTES',
- schedule: {
- minute: newMinute,
+ triggerOptions.onTriggerUpdate({
+ ...trigger,
+ settings: {
+ ...trigger.settings,
+ type: 'MINUTES',
+ schedule: {
+ minute: newMinute,
+ },
},
- },
- });
- }}
- placeholder="Enter number greater than 1"
- readonly={triggerOptions.readonly}
- />
+ });
+ }}
+ placeholder={t`Enter number greater than 1`}
+ readonly={triggerOptions.readonly}
+ />
+
+ >
)}
>
diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/cronstrueComparison.test.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/cronstrueComparison.test.ts
new file mode 100644
index 00000000000..f38b11cc5b1
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/cronstrueComparison.test.ts
@@ -0,0 +1,81 @@
+import { describeCronExpression } from '@/workflow/workflow-trigger/utils/cron-to-human/describeCronExpression';
+
+describe('cronstrue comparison tests', () => {
+ describe('comparing with cronstrue examples', () => {
+ it('should handle "* * * * *" like cronstrue', () => {
+ // cronstrue: "Every minute"
+ expect(describeCronExpression('* * * * *')).toBe('every minute');
+ });
+
+ it('should handle "0 23 * * 1-5" like cronstrue', () => {
+ // cronstrue: "At 11:00 PM, Monday through Friday" (but we use 24h format)
+ expect(describeCronExpression('0 23 * * 1-5')).toBe(
+ 'at 23:00 on weekdays',
+ );
+ });
+
+ it('should handle "0 23 * * *" like cronstrue', () => {
+ // cronstrue: "At 11:00 PM, every day" (but we use 24h format and simpler wording)
+ expect(describeCronExpression('0 23 * * *')).toBe('at 23:00');
+ });
+
+ it('should handle "23 12 * * 0#2" like cronstrue', () => {
+ // cronstrue: "At 12:23 PM, on the second Sunday of the month" (but we use 24h format)
+ expect(describeCronExpression('23 12 * * 0#2')).toBe(
+ 'at 12:23 on the second Sunday of the month',
+ );
+ });
+
+ it('should handle "23 14 * * 0#2" like cronstrue', () => {
+ // cronstrue: "At 14:23, on the second Sunday of the month"
+ expect(describeCronExpression('23 14 * * 0#2')).toBe(
+ 'at 14:23 on the second Sunday of the month',
+ );
+ });
+
+ it('should handle "* * * 6-8 *" like cronstrue', () => {
+ // cronstrue: "Every minute, July through September" (with monthStartIndexZero: true)
+ // Our version uses standard indexing (1-12) so 6-8 = June-August
+ expect(describeCronExpression('* * * 6-8 *')).toBe(
+ 'every minute between June and August',
+ );
+ });
+ });
+
+ describe('additional complex patterns', () => {
+ it('should handle business hours patterns', () => {
+ expect(describeCronExpression('*/15 9-17 * * 1-5')).toBe(
+ 'every 15 minutes between 09:00 and 17:00 on weekdays',
+ );
+ });
+
+ it('should handle monthly patterns', () => {
+ expect(describeCronExpression('0 9 1 */3 *')).toBe(
+ 'at 09:00 on the 1st of the month every 3 months',
+ );
+ });
+
+ it('should handle last day patterns', () => {
+ expect(describeCronExpression('0 23 L * *')).toBe(
+ 'at 23:00 on the last day of the month',
+ );
+ });
+
+ it('should handle last Friday patterns', () => {
+ expect(describeCronExpression('0 17 * * 5L')).toBe(
+ 'at 17:00 on the last Friday of the month',
+ );
+ });
+ });
+
+ describe('12-hour format comparison', () => {
+ it('should format in 12-hour when requested', () => {
+ expect(
+ describeCronExpression('0 14 * * *', { use24HourTimeFormat: false }),
+ ).toBe('at 2:00 PM');
+ expect(
+ describeCronExpression('23 12 * * 0#2', { use24HourTimeFormat: false }),
+ ).toBe('at 12:23 PM on the second Sunday of the month');
+ });
+ });
+});
diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/describeCronExpression.test.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/describeCronExpression.test.ts
new file mode 100644
index 00000000000..5c704c0161e
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/describeCronExpression.test.ts
@@ -0,0 +1,188 @@
+import { describeCronExpression } from '@/workflow/workflow-trigger/utils/cron-to-human/describeCronExpression';
+
+describe('describeCronExpression', () => {
+ describe('basic expressions', () => {
+ it('should describe every minute', () => {
+ expect(describeCronExpression('* * * * *')).toBe('every minute');
+ });
+
+ it('should describe every 5 minutes', () => {
+ expect(describeCronExpression('*/5 * * * *')).toBe('every 5 minutes');
+ });
+
+ it('should describe every hour', () => {
+ expect(describeCronExpression('0 * * * *')).toBe('every hour');
+ });
+
+ it('should describe every 2 hours', () => {
+ expect(describeCronExpression('0 */2 * * *')).toBe('every 2 hours');
+ });
+
+ it('should describe daily at specific time', () => {
+ expect(describeCronExpression('30 14 * * *')).toBe('at 14:30');
+ });
+
+ it('should describe daily at midnight', () => {
+ expect(describeCronExpression('0 0 * * *')).toBe('at 00:00');
+ });
+ });
+
+ describe('day-specific expressions', () => {
+ it('should describe every day', () => {
+ expect(describeCronExpression('0 9 * * *')).toBe('at 09:00');
+ });
+
+ it('should describe every 3 days', () => {
+ expect(describeCronExpression('0 9 */3 * *')).toBe(
+ 'at 09:00 every 3 days',
+ );
+ });
+
+ it('should describe weekdays', () => {
+ expect(describeCronExpression('0 9 * * 1-5')).toBe(
+ 'at 09:00 on weekdays',
+ );
+ });
+
+ it('should describe specific day of month', () => {
+ expect(describeCronExpression('0 9 15 * *')).toBe(
+ 'at 09:00 on the 15th of the month',
+ );
+ });
+
+ it('should describe last day of month', () => {
+ expect(describeCronExpression('0 9 L * *')).toBe(
+ 'at 09:00 on the last day of the month',
+ );
+ });
+ });
+
+ describe('month-specific expressions', () => {
+ it('should describe specific month', () => {
+ expect(describeCronExpression('0 9 1 1 *')).toBe(
+ 'at 09:00 on the 1st of the month only in January',
+ );
+ });
+
+ it('should describe multiple months', () => {
+ expect(describeCronExpression('0 9 * 1,6,12 *')).toBe(
+ 'at 09:00 only in January, June and December',
+ );
+ });
+
+ it('should describe month range', () => {
+ expect(describeCronExpression('0 9 * 6-8 *')).toBe(
+ 'at 09:00 between June and August',
+ );
+ });
+
+ it('should describe every 3 months', () => {
+ expect(describeCronExpression('0 9 1 */3 *')).toBe(
+ 'at 09:00 on the 1st of the month every 3 months',
+ );
+ });
+ });
+
+ describe('complex expressions', () => {
+ it('should describe business hours every 15 minutes on weekdays', () => {
+ expect(describeCronExpression('*/15 9-17 * * 1-5')).toBe(
+ 'every 15 minutes between 09:00 and 17:00 on weekdays',
+ );
+ });
+
+ it('should describe first Monday of every month', () => {
+ expect(describeCronExpression('0 9 * * 1#1')).toBe(
+ 'at 09:00 on the first Monday of the month',
+ );
+ });
+
+ it('should describe last Friday of every month', () => {
+ expect(describeCronExpression('0 17 * * 5L')).toBe(
+ 'at 17:00 on the last Friday of the month',
+ );
+ });
+
+ it('should describe multiple specific times', () => {
+ expect(describeCronExpression('0 9,12,15 * * *')).toBe(
+ 'at 09:00, 12:00 and 15:00',
+ );
+ });
+
+ it('should describe range of minutes', () => {
+ expect(describeCronExpression('15-45 * * * *')).toBe(
+ 'between minute 15 and 45',
+ );
+ });
+
+ it('should describe specific minutes on specific hours', () => {
+ expect(describeCronExpression('30 9,14 * * *')).toBe(
+ 'at 09:30 and 14:30',
+ );
+ });
+ });
+
+ describe('real-world complex expressions', () => {
+ it('should describe business hours every 15 minutes on weekdays', () => {
+ expect(describeCronExpression('*/15 9-17 * * 1-5')).toBe(
+ 'every 15 minutes between 09:00 and 17:00 on weekdays',
+ );
+ });
+
+ it('should describe quarterly reports', () => {
+ expect(describeCronExpression('0 9 1 1,4,7,10 *')).toBe(
+ 'at 09:00 on the 1st of the month only in January, April, July and October',
+ );
+ });
+
+ it('should describe range of minutes', () => {
+ expect(describeCronExpression('15-45 * * * *')).toBe(
+ 'between minute 15 and 45',
+ );
+ });
+
+ it('should describe reduced format expressions', () => {
+ expect(describeCronExpression('9 * * *')).toBe('at 09:00');
+ expect(describeCronExpression('*/2 * * *')).toBe('every 2 hours');
+ expect(describeCronExpression('9 15 * *')).toBe(
+ 'at 09:00 on the 15th of the month',
+ );
+ expect(describeCronExpression('9 * * 1')).toBe('at 09:00 only on Monday');
+ });
+ });
+
+ describe('edge cases', () => {
+ it('should handle empty expression', () => {
+ expect(() => describeCronExpression('')).toThrow(
+ 'Cron expression is required',
+ );
+ });
+
+ it('should handle invalid expression', () => {
+ expect(() => describeCronExpression('invalid')).toThrow(
+ 'Failed to describe cron expression',
+ );
+ });
+
+ it('should handle expression with too many fields', () => {
+ expect(() => describeCronExpression('0 0 0 0 0 0 0 0')).toThrow(
+ 'Failed to describe cron expression',
+ );
+ });
+ });
+
+ describe('12-hour format', () => {
+ it('should use 12-hour format when specified', () => {
+ expect(
+ describeCronExpression('0 14 * * *', { use24HourTimeFormat: false }),
+ ).toBe('at 2:00 PM');
+ });
+
+ it('should use 12-hour format for multiple times', () => {
+ expect(
+ describeCronExpression('0 9,14,18 * * *', {
+ use24HourTimeFormat: false,
+ }),
+ ).toBe('at 9:00 AM, 2:00 PM and 6:00 PM');
+ });
+ });
+});
diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getDayOfMonthDescription.test.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getDayOfMonthDescription.test.ts
new file mode 100644
index 00000000000..6fd838c0fba
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getDayOfMonthDescription.test.ts
@@ -0,0 +1,105 @@
+import { getDayOfMonthDescription } from '@/workflow/workflow-trigger/utils/cron-to-human/descriptors/getDayOfMonthDescription';
+import { DEFAULT_CRON_DESCRIPTION_OPTIONS } from '@/workflow/workflow-trigger/utils/cron-to-human/types/cronDescriptionOptions';
+
+describe('getDayOfMonthDescription', () => {
+ const options = DEFAULT_CRON_DESCRIPTION_OPTIONS;
+
+ it('should handle wildcard (every day)', () => {
+ expect(getDayOfMonthDescription('*', options)).toBe('every day');
+ });
+
+ it('should handle last day of month', () => {
+ expect(getDayOfMonthDescription('L', options)).toBe(
+ 'on the last day of the month',
+ );
+ });
+
+ it('should handle single days', () => {
+ expect(getDayOfMonthDescription('1', options)).toBe(
+ 'on the 1st of the month',
+ );
+ expect(getDayOfMonthDescription('2', options)).toBe(
+ 'on the 2nd of the month',
+ );
+ expect(getDayOfMonthDescription('3', options)).toBe(
+ 'on the 3rd of the month',
+ );
+ expect(getDayOfMonthDescription('15', options)).toBe(
+ 'on the 15th of the month',
+ );
+ expect(getDayOfMonthDescription('21', options)).toBe(
+ 'on the 21st of the month',
+ );
+ expect(getDayOfMonthDescription('22', options)).toBe(
+ 'on the 22nd of the month',
+ );
+ expect(getDayOfMonthDescription('23', options)).toBe(
+ 'on the 23rd of the month',
+ );
+ expect(getDayOfMonthDescription('31', options)).toBe(
+ 'on the 31st of the month',
+ );
+ });
+
+ it('should handle step values', () => {
+ expect(getDayOfMonthDescription('*/5', options)).toBe('every 5 days');
+ expect(getDayOfMonthDescription('*/1', options)).toBe('every day');
+ expect(getDayOfMonthDescription('*/10', options)).toBe('every 10 days');
+ });
+
+ it('should handle range with step', () => {
+ expect(getDayOfMonthDescription('1-15/3', options)).toBe(
+ 'every 3 days, between the 1st and 15th of the month',
+ );
+ expect(getDayOfMonthDescription('10-20/2', options)).toBe(
+ 'every 2 days, between the 10th and 20th of the month',
+ );
+ });
+
+ it('should handle ranges', () => {
+ expect(getDayOfMonthDescription('1-15', options)).toBe(
+ 'between the 1st and 15th of the month',
+ );
+ expect(getDayOfMonthDescription('10-20', options)).toBe(
+ 'between the 10th and 20th of the month',
+ );
+ });
+
+ it('should handle lists', () => {
+ expect(getDayOfMonthDescription('1,15', options)).toBe(
+ 'on the 1st and 15th of the month',
+ );
+ expect(getDayOfMonthDescription('1,10,20,31', options)).toBe(
+ 'on the 1st, 10th, 20th and 31st of the month',
+ );
+ });
+
+ it('should handle weekday specifier', () => {
+ expect(getDayOfMonthDescription('15W', options)).toBe(
+ 'on the weekday closest to the 15th of the month',
+ );
+ expect(getDayOfMonthDescription('1W', options)).toBe(
+ 'on the weekday closest to the 1st of the month',
+ );
+ expect(getDayOfMonthDescription('W', options)).toBe('on weekdays only');
+ });
+
+ it('should handle ordinal numbers correctly', () => {
+ // Test ordinal suffix logic
+ expect(getDayOfMonthDescription('11', options)).toBe(
+ 'on the 11th of the month',
+ );
+ expect(getDayOfMonthDescription('12', options)).toBe(
+ 'on the 12th of the month',
+ );
+ expect(getDayOfMonthDescription('13', options)).toBe(
+ 'on the 13th of the month',
+ );
+ });
+
+ it('should handle edge cases', () => {
+ expect(getDayOfMonthDescription('', options)).toBe('');
+ expect(getDayOfMonthDescription(' ', options)).toBe('');
+ expect(getDayOfMonthDescription('invalid', options)).toBe('invalid');
+ });
+});
diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getDayOfWeekDescription.test.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getDayOfWeekDescription.test.ts
new file mode 100644
index 00000000000..ced84c68779
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getDayOfWeekDescription.test.ts
@@ -0,0 +1,86 @@
+import { getDayOfWeekDescription } from '@/workflow/workflow-trigger/utils/cron-to-human/descriptors/getDayOfWeekDescription';
+import { DEFAULT_CRON_DESCRIPTION_OPTIONS } from '@/workflow/workflow-trigger/utils/cron-to-human/types/cronDescriptionOptions';
+
+describe('getDayOfWeekDescription', () => {
+ const options = DEFAULT_CRON_DESCRIPTION_OPTIONS;
+
+ it('should handle wildcard (every day)', () => {
+ expect(getDayOfWeekDescription('*', options)).toBe('');
+ });
+
+ it('should handle single days', () => {
+ expect(getDayOfWeekDescription('0', options)).toBe('only on Sunday');
+ expect(getDayOfWeekDescription('1', options)).toBe('only on Monday');
+ expect(getDayOfWeekDescription('6', options)).toBe('only on Saturday');
+ expect(getDayOfWeekDescription('7', options)).toBe('only on Sunday'); // 7 = Sunday
+ });
+
+ it('should handle weekdays', () => {
+ expect(getDayOfWeekDescription('1-5', options)).toBe('on weekdays');
+ });
+
+ it('should handle weekends', () => {
+ expect(getDayOfWeekDescription('0,6', options)).toBe(
+ 'only on Sunday and Saturday',
+ );
+ });
+
+ it('should handle day ranges', () => {
+ expect(getDayOfWeekDescription('1-3', options)).toBe(
+ 'from Monday to Wednesday',
+ );
+ expect(getDayOfWeekDescription('4-6', options)).toBe(
+ 'from Thursday to Saturday',
+ );
+ });
+
+ it('should handle day lists', () => {
+ expect(getDayOfWeekDescription('1,3,5', options)).toBe(
+ 'only on Monday, Wednesday and Friday',
+ );
+ expect(getDayOfWeekDescription('0,6', options)).toBe(
+ 'only on Sunday and Saturday',
+ );
+ });
+
+ it('should handle step values', () => {
+ expect(getDayOfWeekDescription('*/2', options)).toBe('every 2 days');
+ expect(getDayOfWeekDescription('*/1', options)).toBe('every day');
+ });
+
+ it('should handle nth occurrence of weekday', () => {
+ expect(getDayOfWeekDescription('1#1', options)).toBe(
+ 'on the first Monday of the month',
+ );
+ expect(getDayOfWeekDescription('5#2', options)).toBe(
+ 'on the second Friday of the month',
+ );
+ expect(getDayOfWeekDescription('0#3', options)).toBe(
+ 'on the third Sunday of the month',
+ );
+ });
+
+ it('should handle last occurrence of weekday', () => {
+ expect(getDayOfWeekDescription('1L', options)).toBe(
+ 'on the last Monday of the month',
+ );
+ expect(getDayOfWeekDescription('5L', options)).toBe(
+ 'on the last Friday of the month',
+ );
+ });
+
+ it('should handle different start index options', () => {
+ const optionsStartFromOne = { ...options, dayOfWeekStartIndexZero: false };
+
+ // When dayOfWeekStartIndexZero is false, 1=Sunday, 2=Monday, etc.
+ expect(getDayOfWeekDescription('2-6', optionsStartFromOne)).toBe(
+ 'on weekdays',
+ );
+ });
+
+ it('should handle edge cases', () => {
+ expect(getDayOfWeekDescription('', options)).toBe('');
+ expect(getDayOfWeekDescription(' ', options)).toBe('');
+ expect(getDayOfWeekDescription('invalid', options)).toBe('invalid');
+ });
+});
diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getHoursDescription.test.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getHoursDescription.test.ts
new file mode 100644
index 00000000000..de61ab7e32b
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getHoursDescription.test.ts
@@ -0,0 +1,85 @@
+import { getHoursDescription } from '@/workflow/workflow-trigger/utils/cron-to-human/descriptors/getHoursDescription';
+import { DEFAULT_CRON_DESCRIPTION_OPTIONS } from '@/workflow/workflow-trigger/utils/cron-to-human/types/cronDescriptionOptions';
+
+describe('getHoursDescription', () => {
+ const options24 = {
+ ...DEFAULT_CRON_DESCRIPTION_OPTIONS,
+ use24HourTimeFormat: true,
+ };
+ const options12 = {
+ ...DEFAULT_CRON_DESCRIPTION_OPTIONS,
+ use24HourTimeFormat: false,
+ };
+
+ describe('24-hour format', () => {
+ it('should handle wildcard', () => {
+ expect(getHoursDescription('*', '0', options24)).toBe('every hour');
+ });
+
+ it('should handle step values', () => {
+ expect(getHoursDescription('*/2', '0', options24)).toBe('every 2 hours');
+ expect(getHoursDescription('*/1', '0', options24)).toBe('every hour');
+ });
+
+ it('should handle range with step', () => {
+ expect(getHoursDescription('9-17/2', '0', options24)).toBe(
+ 'every 2 hours, between 09:00 and 17:00',
+ );
+ });
+
+ it('should handle ranges', () => {
+ expect(getHoursDescription('9-17', '0', options24)).toBe(
+ 'between 09:00 and 17:00',
+ );
+ });
+
+ it('should handle lists', () => {
+ expect(getHoursDescription('9,12', '0', options24)).toBe(
+ 'at 09:00 and 12:00',
+ );
+ expect(getHoursDescription('9,12,15,18', '30', options24)).toBe(
+ 'at 09:30, 12:30, 15:30 and 18:30',
+ );
+ });
+
+ it('should handle single values', () => {
+ expect(getHoursDescription('9', '30', options24)).toBe('at 09:30');
+ expect(getHoursDescription('0', '0', options24)).toBe('at 00:00');
+ expect(getHoursDescription('23', '59', options24)).toBe('at 23:59');
+ });
+ });
+
+ describe('12-hour format', () => {
+ it('should format morning times', () => {
+ expect(getHoursDescription('9', '30', options12)).toBe('at 9:30 AM');
+ expect(getHoursDescription('0', '0', options12)).toBe('at 12:00 AM');
+ });
+
+ it('should format afternoon times', () => {
+ expect(getHoursDescription('14', '30', options12)).toBe('at 2:30 PM');
+ expect(getHoursDescription('12', '0', options12)).toBe('at 12:00 PM');
+ });
+
+ it('should format lists in 12-hour', () => {
+ expect(getHoursDescription('9,14', '0', options12)).toBe(
+ 'at 9:00 AM and 2:00 PM',
+ );
+ });
+
+ it('should format ranges in 12-hour', () => {
+ expect(getHoursDescription('9-17', '0', options12)).toBe(
+ 'between 9:00 AM and 5:00 PM',
+ );
+ });
+ });
+
+ describe('edge cases', () => {
+ it('should handle empty hours', () => {
+ expect(getHoursDescription('', '0', options24)).toBe('');
+ });
+
+ it('should handle invalid hours', () => {
+ expect(getHoursDescription('invalid', '0', options24)).toBe('invalid');
+ });
+ });
+});
diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getMinutesDescription.test.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getMinutesDescription.test.ts
new file mode 100644
index 00000000000..05ae1a2d0e8
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getMinutesDescription.test.ts
@@ -0,0 +1,56 @@
+import { getMinutesDescription } from '@/workflow/workflow-trigger/utils/cron-to-human/descriptors/getMinutesDescription';
+import { DEFAULT_CRON_DESCRIPTION_OPTIONS } from '@/workflow/workflow-trigger/utils/cron-to-human/types/cronDescriptionOptions';
+
+describe('getMinutesDescription', () => {
+ const options = DEFAULT_CRON_DESCRIPTION_OPTIONS;
+
+ it('should handle wildcard', () => {
+ expect(getMinutesDescription('*', options)).toBe('every minute');
+ });
+
+ it('should handle step values', () => {
+ expect(getMinutesDescription('*/5', options)).toBe('every 5 minutes');
+ expect(getMinutesDescription('*/15', options)).toBe('every 15 minutes');
+ expect(getMinutesDescription('*/1', options)).toBe('every minute');
+ });
+
+ it('should handle range with step', () => {
+ expect(getMinutesDescription('10-30/5', options)).toBe(
+ 'every 5 minutes, between minute 10 and 30',
+ );
+ });
+
+ it('should handle ranges', () => {
+ expect(getMinutesDescription('10-20', options)).toBe(
+ 'between minute 10 and 20',
+ );
+ expect(getMinutesDescription('0-59', options)).toBe(
+ 'between minute 0 and 59',
+ );
+ });
+
+ it('should handle lists', () => {
+ expect(getMinutesDescription('10,20', options)).toBe(
+ 'at minutes 10 and 20',
+ );
+ expect(getMinutesDescription('0,15,30,45', options)).toBe(
+ 'at minutes 0, 15, 30 and 45',
+ );
+ });
+
+ it('should handle single values', () => {
+ expect(getMinutesDescription('0', options)).toBe('at the top of the hour');
+ expect(getMinutesDescription('1', options)).toBe(
+ 'at 1 minute past the hour',
+ );
+ expect(getMinutesDescription('30', options)).toBe(
+ 'at 30 minutes past the hour',
+ );
+ });
+
+ it('should handle edge cases', () => {
+ expect(getMinutesDescription('', options)).toBe('');
+ expect(getMinutesDescription(' ', options)).toBe('');
+ expect(getMinutesDescription('invalid', options)).toBe('invalid');
+ });
+});
diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getMonthsDescription.test.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getMonthsDescription.test.ts
new file mode 100644
index 00000000000..1ae4233a19a
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/getMonthsDescription.test.ts
@@ -0,0 +1,89 @@
+import { getMonthsDescription } from '@/workflow/workflow-trigger/utils/cron-to-human/descriptors/getMonthsDescription';
+import { DEFAULT_CRON_DESCRIPTION_OPTIONS } from '@/workflow/workflow-trigger/utils/cron-to-human/types/cronDescriptionOptions';
+
+describe('getMonthsDescription', () => {
+ const options = DEFAULT_CRON_DESCRIPTION_OPTIONS;
+
+ it('should handle wildcard (every month)', () => {
+ expect(getMonthsDescription('*', options)).toBe('');
+ });
+
+ it('should handle single months', () => {
+ expect(getMonthsDescription('1', options)).toBe('only in January');
+ expect(getMonthsDescription('6', options)).toBe('only in June');
+ expect(getMonthsDescription('12', options)).toBe('only in December');
+ });
+
+ it('should handle step values', () => {
+ expect(getMonthsDescription('*/3', options)).toBe('every 3 months');
+ expect(getMonthsDescription('*/6', options)).toBe('every 6 months');
+ expect(getMonthsDescription('*/1', options)).toBe('');
+ });
+
+ it('should handle range with step', () => {
+ expect(getMonthsDescription('1-6/2', options)).toBe(
+ 'every 2 months, between January and June',
+ );
+ expect(getMonthsDescription('3-9/3', options)).toBe(
+ 'every 3 months, between March and September',
+ );
+ });
+
+ it('should handle ranges', () => {
+ expect(getMonthsDescription('1-6', options)).toBe(
+ 'between January and June',
+ );
+ expect(getMonthsDescription('6-8', options)).toBe(
+ 'between June and August',
+ );
+ });
+
+ it('should handle lists', () => {
+ expect(getMonthsDescription('1,6', options)).toBe(
+ 'only in January and June',
+ );
+ expect(getMonthsDescription('1,6,12', options)).toBe(
+ 'only in January, June and December',
+ );
+ expect(getMonthsDescription('3,6,9,12', options)).toBe(
+ 'only in March, June, September and December',
+ );
+ });
+
+ it('should handle month start index options', () => {
+ const optionsZeroIndex = { ...options, monthStartIndexZero: true };
+
+ // When monthStartIndexZero is true, 0=January, 1=February, etc.
+ expect(getMonthsDescription('0', optionsZeroIndex)).toBe('only in January');
+ expect(getMonthsDescription('11', optionsZeroIndex)).toBe(
+ 'only in December',
+ );
+ });
+
+ it('should handle edge cases', () => {
+ expect(getMonthsDescription('', options)).toBe('');
+ expect(getMonthsDescription(' ', options)).toBe('');
+ expect(getMonthsDescription('invalid', options)).toBe('invalid');
+ });
+
+ describe('with locale catalog', () => {
+ // Note: These tests would need a mock locale catalog to test properly
+ // For now, we test that the function doesn't crash with locale
+ it('should handle locale catalog without crashing', () => {
+ // Create a proper mock locale with the required properties for date-fns
+ const mockLocale = {
+ localize: {
+ month: () => 'MockMonth',
+ },
+ formatLong: {
+ date: () => 'P',
+ time: () => 'p',
+ dateTime: () => 'Pp',
+ },
+ } as any;
+ expect(() =>
+ getMonthsDescription('1', options, mockLocale),
+ ).not.toThrow();
+ });
+ });
+});
diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/parseCronExpression.test.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/parseCronExpression.test.ts
new file mode 100644
index 00000000000..b5faefb1a38
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/__tests__/parseCronExpression.test.ts
@@ -0,0 +1,69 @@
+import { parseCronExpression } from '@/workflow/workflow-trigger/utils/cron-to-human/utils/parseCronExpression';
+
+describe('parseCronExpression', () => {
+ it('should parse 4-field cron expression', () => {
+ const result = parseCronExpression('0 * * *');
+ expect(result).toEqual({
+ seconds: '0',
+ minutes: '0',
+ hours: '0',
+ dayOfMonth: '*',
+ month: '*',
+ dayOfWeek: '*',
+ });
+ });
+
+ it('should parse 5-field cron expression', () => {
+ const result = parseCronExpression('30 14 * * 1');
+ expect(result).toEqual({
+ seconds: '0',
+ minutes: '30',
+ hours: '14',
+ dayOfMonth: '*',
+ month: '*',
+ dayOfWeek: '1',
+ });
+ });
+
+ it('should parse 6-field cron expression with seconds', () => {
+ const result = parseCronExpression('15 30 14 * * 1');
+ expect(result).toEqual({
+ seconds: '15',
+ minutes: '30',
+ hours: '14',
+ dayOfMonth: '*',
+ month: '*',
+ dayOfWeek: '1',
+ });
+ });
+
+ it('should throw error for invalid field count', () => {
+ expect(() => parseCronExpression('* *')).toThrow(
+ 'Invalid cron expression format. Expected 4, 5, or 6 fields, got 2',
+ );
+ });
+
+ it('should throw error for empty expression', () => {
+ expect(() => parseCronExpression('')).toThrow(
+ 'Cron expression is required',
+ );
+ });
+
+ it('should throw error for invalid cron syntax', () => {
+ expect(() => parseCronExpression('invalid cron expression')).toThrow(
+ 'Invalid cron expression',
+ );
+ });
+
+ it('should handle complex expressions with ranges and steps', () => {
+ const result = parseCronExpression('*/15 9-17 1-15 1,6,12 1-5');
+ expect(result).toEqual({
+ seconds: '0',
+ minutes: '*/15',
+ hours: '9-17',
+ dayOfMonth: '1-15',
+ month: '1,6,12',
+ dayOfWeek: '1-5',
+ });
+ });
+});
diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/describeCronExpression.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/describeCronExpression.ts
new file mode 100644
index 00000000000..046f764ec20
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/describeCronExpression.ts
@@ -0,0 +1,116 @@
+import { t } from '@lingui/core/macro';
+import { type Locale } from 'date-fns';
+import { isDefined } from 'twenty-shared/utils';
+
+import { getDayOfMonthDescription } from './descriptors/getDayOfMonthDescription';
+import { getDayOfWeekDescription } from './descriptors/getDayOfWeekDescription';
+import { getHoursDescription } from './descriptors/getHoursDescription';
+import { getMinutesDescription } from './descriptors/getMinutesDescription';
+import { getMonthsDescription } from './descriptors/getMonthsDescription';
+import {
+ type CronDescriptionOptions,
+ DEFAULT_CRON_DESCRIPTION_OPTIONS,
+} from './types/cronDescriptionOptions';
+import { parseCronExpression } from './utils/parseCronExpression';
+
+export const describeCronExpression = (
+ expression: string,
+ options: CronDescriptionOptions = DEFAULT_CRON_DESCRIPTION_OPTIONS,
+ localeCatalog?: Locale,
+): string => {
+ if (!isDefined(expression) || expression.trim() === '') {
+ throw new Error('Cron expression is required');
+ }
+
+ try {
+ const parts = parseCronExpression(expression);
+ const mergedOptions = { ...DEFAULT_CRON_DESCRIPTION_OPTIONS, ...options };
+
+ const descriptions: string[] = [];
+
+ // Smart priority: Use hours description when we have specific hours,
+ // otherwise use minutes description for patterns like */5
+ const hoursDescription = getHoursDescription(
+ parts.hours,
+ parts.minutes,
+ mergedOptions,
+ );
+ const minutesDescription = getMinutesDescription(
+ parts.minutes,
+ mergedOptions,
+ );
+
+ // Smart logic for time descriptions
+ if (parts.minutes.includes('/') && parts.hours.includes('-')) {
+ // Special case: minute intervals with hour ranges (e.g., "*/15 9-17")
+ if (isDefined(minutesDescription) && minutesDescription !== '') {
+ descriptions.push(minutesDescription);
+ }
+ if (isDefined(hoursDescription) && hoursDescription !== '') {
+ // Remove "at" prefix from hours description when combining
+ const cleanHoursDesc = hoursDescription.replace(/^at\s+/, '');
+ descriptions.push(cleanHoursDesc);
+ }
+ } else if (
+ parts.hours === '*' &&
+ (parts.minutes.includes('/') ||
+ parts.minutes.includes('-') ||
+ parts.minutes === '*')
+ ) {
+ // Pattern like "*/5 * * * *", "15-45 * * * *", or "* * * * *" - prioritize minutes
+ if (isDefined(minutesDescription) && minutesDescription !== '') {
+ descriptions.push(minutesDescription);
+ }
+ } else if (parts.hours === '*' && parts.minutes === '0') {
+ // Pattern like "0 * * * *" - should be "every hour", not "at the top of the hour"
+ descriptions.push(t`every hour`);
+ } else if (isDefined(hoursDescription) && hoursDescription !== '') {
+ // Use hours description for specific hours or hour patterns
+ descriptions.push(hoursDescription);
+ } else if (isDefined(minutesDescription) && minutesDescription !== '') {
+ // Fallback to minutes description
+ descriptions.push(minutesDescription);
+ }
+ const dayOfMonthDesc = getDayOfMonthDescription(
+ parts.dayOfMonth,
+ mergedOptions,
+ );
+ const dayOfWeekDesc = getDayOfWeekDescription(
+ parts.dayOfWeek,
+ mergedOptions,
+ localeCatalog,
+ );
+
+ if (
+ isDefined(dayOfMonthDesc) &&
+ dayOfMonthDesc !== '' &&
+ parts.dayOfMonth !== '*'
+ ) {
+ descriptions.push(dayOfMonthDesc);
+ } else if (
+ isDefined(dayOfWeekDesc) &&
+ dayOfWeekDesc !== '' &&
+ parts.dayOfWeek !== '*'
+ ) {
+ descriptions.push(dayOfWeekDesc);
+ }
+ const monthsDesc = getMonthsDescription(
+ parts.month,
+ mergedOptions,
+ localeCatalog,
+ );
+ if (isDefined(monthsDesc) && monthsDesc !== '') {
+ descriptions.push(monthsDesc);
+ }
+
+ if (descriptions.length === 0) {
+ return t`every minute`;
+ }
+ // Simple joining - just use spaces, no commas for cleaner descriptions
+ return descriptions.join(' ');
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : 'Unknown error';
+ throw new Error(`Failed to describe cron expression: ${errorMessage}`);
+ }
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getDayOfMonthDescription.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getDayOfMonthDescription.ts
new file mode 100644
index 00000000000..dbe630ad97d
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getDayOfMonthDescription.ts
@@ -0,0 +1,101 @@
+import { t } from '@lingui/core/macro';
+import { isDefined } from 'twenty-shared/utils';
+
+import { getOrdinalNumber } from '~/utils/format/getOrdinalNumber';
+import { isListValue } from '~/utils/validation/isListValue';
+import { isNumericRange } from '~/utils/validation/isNumericRange';
+import { isStepValue } from '~/utils/validation/isStepValue';
+import { type CronDescriptionOptions } from '../types/cronDescriptionOptions';
+
+export const getDayOfMonthDescription = (
+ dayOfMonth: string,
+ _options: CronDescriptionOptions,
+): string => {
+ if (!isDefined(dayOfMonth) || dayOfMonth.trim() === '') {
+ return '';
+ }
+
+ // Every day
+ if (dayOfMonth === '*') {
+ return t`every day`;
+ }
+
+ // Last day of month
+ if (dayOfMonth === 'L') {
+ return t`on the last day of the month`;
+ }
+
+ // Weekday (W) - closest weekday to the given date
+ if (dayOfMonth.includes('W')) {
+ const day = dayOfMonth.replace('W', '');
+ const dayNum = parseInt(day, 10);
+ if (!isNaN(dayNum)) {
+ const ordinalDay = getOrdinalNumber(dayNum);
+ return t`on the weekday closest to the ${ordinalDay} of the month`;
+ }
+ return t`on weekdays only`;
+ }
+
+ // Step values (e.g., "*/5" = every 5 days)
+ if (isStepValue(dayOfMonth)) {
+ const [range, step] = dayOfMonth.split('/');
+ const stepNum = parseInt(step, 10);
+
+ if (range === '*') {
+ if (stepNum === 1) {
+ return t`every day`;
+ }
+ const stepNumStr = stepNum.toString();
+ return t`every ${stepNumStr} days`;
+ }
+
+ // Range with step (e.g., "1-15/3")
+ if (range.includes('-')) {
+ const [start, end] = range.split('-');
+ const stepNumStr = stepNum.toString();
+ const startOrdinal = getOrdinalNumber(parseInt(start, 10));
+ const endOrdinal = getOrdinalNumber(parseInt(end, 10));
+ return t`every ${stepNumStr} days, between the ${startOrdinal} and ${endOrdinal} of the month`;
+ }
+
+ const stepNumStr = stepNum.toString();
+ return t`every ${stepNumStr} days`;
+ }
+
+ // Range values (e.g., "1-15")
+ if (isNumericRange(dayOfMonth) && dayOfMonth.includes('-')) {
+ const [start, end] = dayOfMonth.split('-');
+ const startNum = parseInt(start, 10);
+ const endNum = parseInt(end, 10);
+ const startOrdinal = getOrdinalNumber(startNum);
+ const endOrdinal = getOrdinalNumber(endNum);
+ return t`between the ${startOrdinal} and ${endOrdinal} of the month`;
+ }
+
+ // List values (e.g., "1,15,30")
+ if (isListValue(dayOfMonth)) {
+ const values = dayOfMonth.split(',').map((v) => v.trim());
+ const ordinalDays = values.map((day) => {
+ const dayNum = parseInt(day, 10);
+ return !isNaN(dayNum) ? getOrdinalNumber(dayNum) : day;
+ });
+
+ if (ordinalDays.length === 2) {
+ const firstDay = ordinalDays[0];
+ const secondDay = ordinalDays[1];
+ return t`on the ${firstDay} and ${secondDay} of the month`;
+ }
+ const lastDay = ordinalDays.pop();
+ const remainingDays = ordinalDays.join(', ');
+ return t`on the ${remainingDays} and ${lastDay} of the month`;
+ }
+
+ // Single day value
+ const dayNum = parseInt(dayOfMonth, 10);
+ if (!isNaN(dayNum)) {
+ const ordinalDay = getOrdinalNumber(dayNum);
+ return t`on the ${ordinalDay} of the month`;
+ }
+
+ return dayOfMonth;
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getDayOfWeekDescription.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getDayOfWeekDescription.ts
new file mode 100644
index 00000000000..503da30f368
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getDayOfWeekDescription.ts
@@ -0,0 +1,159 @@
+import { t } from '@lingui/core/macro';
+import { format, type Locale } from 'date-fns';
+import { isDefined } from 'twenty-shared/utils';
+
+import { isListValue } from '~/utils/validation/isListValue';
+import { isNumericRange } from '~/utils/validation/isNumericRange';
+import { isStepValue } from '~/utils/validation/isStepValue';
+import { type CronDescriptionOptions } from '../types/cronDescriptionOptions';
+
+const getDayName = (
+ dayNum: number,
+ dayOfWeekStartIndexZero: boolean,
+ localeCatalog?: Locale,
+): string => {
+ // Handle both 0 and 7 as Sunday
+ const normalizedDay = dayNum === 7 ? 0 : dayNum;
+
+ // Create a date for the given day (using a Sunday as base: 2024-01-07)
+ const baseDate = new Date(2024, 0, 7); // Sunday
+ const dayDate = new Date(
+ baseDate.getTime() + normalizedDay * 24 * 60 * 60 * 1000,
+ );
+
+ if (isDefined(localeCatalog)) {
+ return format(dayDate, 'EEEE', { locale: localeCatalog });
+ }
+
+ return format(dayDate, 'EEEE');
+};
+
+export const getDayOfWeekDescription = (
+ dayOfWeek: string,
+ options: CronDescriptionOptions,
+ localeCatalog?: Locale,
+): string => {
+ if (!isDefined(dayOfWeek) || dayOfWeek.trim() === '') {
+ return '';
+ }
+
+ // Every day of week
+ if (dayOfWeek === '*') {
+ return '';
+ }
+
+ const dayOfWeekStartIndexZero = options.dayOfWeekStartIndexZero ?? true;
+
+ // Last occurrence of a weekday in the month (e.g., "5L" = last Friday)
+ if (dayOfWeek.includes('L')) {
+ const day = dayOfWeek.replace('L', '');
+ const dayNum = parseInt(day, 10);
+ if (!isNaN(dayNum)) {
+ const dayName = getDayName(
+ dayNum,
+ dayOfWeekStartIndexZero,
+ localeCatalog,
+ );
+ return t`on the last ${dayName} of the month`;
+ }
+ }
+
+ // Nth occurrence of a weekday (e.g., "1#2" = second Monday)
+ if (dayOfWeek.includes('#')) {
+ const [day, occurrence] = dayOfWeek.split('#');
+ const dayNum = parseInt(day, 10);
+ const occurrenceNum = parseInt(occurrence, 10);
+
+ if (!isNaN(dayNum) && !isNaN(occurrenceNum)) {
+ const dayName = getDayName(
+ dayNum,
+ dayOfWeekStartIndexZero,
+ localeCatalog,
+ );
+ const getOrdinals = () => [
+ t`first`,
+ t`second`,
+ t`third`,
+ t`fourth`,
+ t`fifth`,
+ ];
+ const ordinals = getOrdinals();
+ const ordinal = ordinals[occurrenceNum - 1] || occurrenceNum.toString();
+ return t`on the ${ordinal} ${dayName} of the month`;
+ }
+ }
+
+ // Step values (e.g., "*/2" = every other day)
+ if (isStepValue(dayOfWeek)) {
+ const [range, step] = dayOfWeek.split('/');
+ const stepNum = parseInt(step, 10);
+
+ if (range === '*') {
+ if (stepNum === 1) {
+ return t`every day`;
+ }
+ const stepNumStr = stepNum.toString();
+ return t`every ${stepNumStr} days`;
+ }
+
+ return t`every ${stepNum} days`;
+ }
+
+ // Range values (e.g., "1-5" = Monday to Friday)
+ if (isNumericRange(dayOfWeek) && dayOfWeek.includes('-')) {
+ const [start, end] = dayOfWeek.split('-');
+ const startDay = getDayName(
+ parseInt(start, 10),
+ dayOfWeekStartIndexZero,
+ localeCatalog,
+ );
+ const endDay = getDayName(
+ parseInt(end, 10),
+ dayOfWeekStartIndexZero,
+ localeCatalog,
+ );
+
+ // Special case for weekdays
+ if (start === '1' && end === '5' && dayOfWeekStartIndexZero) {
+ return t`on weekdays`;
+ }
+ if (start === '2' && end === '6' && !dayOfWeekStartIndexZero) {
+ return t`on weekdays`;
+ }
+
+ return t`from ${startDay} to ${endDay}`;
+ }
+
+ // List values (e.g., "1,3,5")
+ if (isListValue(dayOfWeek)) {
+ const values = dayOfWeek.split(',').map((v) => v.trim());
+ const dayNames = values.map((day) => {
+ const dayNum = parseInt(day, 10);
+ return !isNaN(dayNum)
+ ? getDayName(dayNum, dayOfWeekStartIndexZero, localeCatalog)
+ : day;
+ });
+
+ if (dayNames.length === 1) {
+ const dayName = dayNames[0];
+ return t`only on ${dayName}`;
+ }
+ if (dayNames.length === 2) {
+ const firstDay = dayNames[0];
+ const secondDay = dayNames[1];
+ return t`only on ${firstDay} and ${secondDay}`;
+ }
+ const lastDay = dayNames.pop();
+ const remainingDays = dayNames.join(', ');
+ return t`only on ${remainingDays} and ${lastDay}`;
+ }
+
+ // Single day value
+ const dayNum = parseInt(dayOfWeek, 10);
+ if (!isNaN(dayNum)) {
+ const dayName = getDayName(dayNum, dayOfWeekStartIndexZero, localeCatalog);
+ return t`only on ${dayName}`;
+ }
+
+ return dayOfWeek;
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getHoursDescription.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getHoursDescription.ts
new file mode 100644
index 00000000000..2119235efc4
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getHoursDescription.ts
@@ -0,0 +1,79 @@
+import { t } from '@lingui/core/macro';
+import { isDefined } from 'twenty-shared/utils';
+
+import { formatTime as formatCronTime } from '~/utils/format/formatTime';
+import { isListValue } from '~/utils/validation/isListValue';
+import { isNumericRange } from '~/utils/validation/isNumericRange';
+import { isStepValue } from '~/utils/validation/isStepValue';
+import { type CronDescriptionOptions } from '../types/cronDescriptionOptions';
+
+export const getHoursDescription = (
+ hours: string,
+ minutes: string,
+ options: CronDescriptionOptions,
+): string => {
+ if (!isDefined(hours) || hours.trim() === '') {
+ return '';
+ }
+
+ const use24Hour = options.use24HourTimeFormat ?? true;
+
+ if (hours === '*') {
+ return t`every hour`;
+ }
+
+ if (isStepValue(hours)) {
+ const [range, step] = hours.split('/');
+ const stepNum = parseInt(step, 10);
+
+ if (range === '*') {
+ if (stepNum === 1) {
+ return t`every hour`;
+ }
+ const stepNumStr = stepNum.toString();
+ return t`every ${stepNumStr} hours`;
+ }
+
+ if (range.includes('-')) {
+ const [start, end] = range.split('-');
+ const stepNumStr = stepNum.toString();
+ const startTime = formatCronTime(start, '0', use24Hour);
+ const endTime = formatCronTime(end, '0', use24Hour);
+ return t`every ${stepNumStr} hours, between ${startTime} and ${endTime}`;
+ }
+
+ const stepNumStr = stepNum.toString();
+ return t`every ${stepNumStr} hours`;
+ }
+
+ if (isNumericRange(hours) && hours.includes('-')) {
+ const [start, end] = hours.split('-');
+ const startTime = formatCronTime(start, '0', use24Hour);
+ const endTime = formatCronTime(end, '0', use24Hour);
+ return t`between ${startTime} and ${endTime}`;
+ }
+
+ if (isListValue(hours)) {
+ const values = hours.split(',').map((v) => v.trim());
+ const formattedTimes = values.map((hour) =>
+ formatCronTime(hour, minutes || '0', use24Hour),
+ );
+
+ if (formattedTimes.length === 2) {
+ const firstTime = formattedTimes[0];
+ const secondTime = formattedTimes[1];
+ return t`at ${firstTime} and ${secondTime}`;
+ }
+ const lastTime = formattedTimes.pop();
+ const remainingTimes = formattedTimes.join(', ');
+ return t`at ${remainingTimes} and ${lastTime}`;
+ }
+
+ const hourNum = parseInt(hours, 10);
+ if (!isNaN(hourNum)) {
+ const formattedTime = formatCronTime(hours, minutes || '0', use24Hour);
+ return t`at ${formattedTime}`;
+ }
+
+ return hours;
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getMinutesDescription.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getMinutesDescription.ts
new file mode 100644
index 00000000000..5fa0253b317
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getMinutesDescription.ts
@@ -0,0 +1,71 @@
+import { t } from '@lingui/core/macro';
+import { isDefined } from 'twenty-shared/utils';
+
+import { isListValue } from '~/utils/validation/isListValue';
+import { isNumericRange } from '~/utils/validation/isNumericRange';
+import { isStepValue } from '~/utils/validation/isStepValue';
+import { type CronDescriptionOptions } from '../types/cronDescriptionOptions';
+
+export const getMinutesDescription = (
+ minutes: string,
+ _options: CronDescriptionOptions,
+): string => {
+ if (!isDefined(minutes) || minutes.trim() === '') {
+ return '';
+ }
+
+ if (minutes === '*') {
+ return t`every minute`;
+ }
+
+ if (isStepValue(minutes)) {
+ const [range, step] = minutes.split('/');
+ const stepNum = parseInt(step, 10);
+ const stepNumStr = stepNum.toString();
+
+ if (range === '*') {
+ if (stepNum === 1) {
+ return t`every minute`;
+ }
+ return t`every ${stepNumStr} minutes`;
+ }
+
+ if (range.includes('-')) {
+ const [start, end] = range.split('-');
+ return t`every ${stepNumStr} minutes, between minute ${start} and ${end}`;
+ }
+
+ return t`every ${stepNumStr} minutes`;
+ }
+
+ if (isNumericRange(minutes) && minutes.includes('-')) {
+ const [start, end] = minutes.split('-');
+ return t`between minute ${start} and ${end}`;
+ }
+
+ if (isListValue(minutes)) {
+ const values = minutes.split(',').map((v) => v.trim());
+ if (values.length === 2) {
+ const firstValue = values[0];
+ const secondValue = values[1];
+ return t`at minutes ${firstValue} and ${secondValue}`;
+ }
+ const lastValue = values.pop();
+ const remainingValues = values.join(', ');
+ return t`at minutes ${remainingValues} and ${lastValue}`;
+ }
+
+ const minuteNum = parseInt(minutes, 10);
+ if (!isNaN(minuteNum)) {
+ if (minuteNum === 0) {
+ return t`at the top of the hour`;
+ }
+ if (minuteNum === 1) {
+ return t`at 1 minute past the hour`;
+ }
+ const minuteNumStr = minuteNum.toString();
+ return t`at ${minuteNumStr} minutes past the hour`;
+ }
+
+ return minutes;
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getMonthsDescription.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getMonthsDescription.ts
new file mode 100644
index 00000000000..5b25b7fa345
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/descriptors/getMonthsDescription.ts
@@ -0,0 +1,127 @@
+import { t } from '@lingui/core/macro';
+import { format, type Locale } from 'date-fns';
+import { isDefined } from 'twenty-shared/utils';
+
+import { isListValue } from '~/utils/validation/isListValue';
+import { isNumericRange } from '~/utils/validation/isNumericRange';
+import { isStepValue } from '~/utils/validation/isStepValue';
+import { type CronDescriptionOptions } from '../types/cronDescriptionOptions';
+
+const getMonthName = (
+ monthNum: number,
+ monthStartIndexZero: boolean,
+ localeCatalog?: Locale,
+): string => {
+ const index = monthStartIndexZero ? monthNum : monthNum - 1;
+
+ // Create a date for the given month (using January 1st as base)
+ const monthDate = new Date(2024, index, 1);
+
+ if (isDefined(localeCatalog)) {
+ return format(monthDate, 'MMMM', { locale: localeCatalog });
+ }
+
+ return format(monthDate, 'MMMM');
+};
+
+export const getMonthsDescription = (
+ months: string,
+ options: CronDescriptionOptions,
+ localeCatalog?: Locale,
+): string => {
+ if (!isDefined(months) || months.trim() === '') {
+ return '';
+ }
+
+ // Every month
+ if (months === '*') {
+ return '';
+ }
+
+ const monthStartIndexZero = options.monthStartIndexZero ?? false;
+
+ // Step values (e.g., "*/3" = every 3 months)
+ if (isStepValue(months)) {
+ const [range, step] = months.split('/');
+ const stepNum = parseInt(step, 10);
+ const stepNumStr = stepNum.toString();
+
+ if (range === '*') {
+ if (stepNum === 1) {
+ return '';
+ }
+ return t`every ${stepNumStr} months`;
+ }
+
+ // Range with step (e.g., "1-6/2")
+ if (range.includes('-')) {
+ const [start, end] = range.split('-');
+ const startMonth = getMonthName(
+ parseInt(start, 10),
+ monthStartIndexZero,
+ localeCatalog,
+ );
+ const endMonth = getMonthName(
+ parseInt(end, 10),
+ monthStartIndexZero,
+ localeCatalog,
+ );
+ return t`every ${stepNumStr} months, between ${startMonth} and ${endMonth}`;
+ }
+
+ return t`every ${stepNumStr} months`;
+ }
+
+ // Range values (e.g., "1-6")
+ if (isNumericRange(months) && months.includes('-')) {
+ const [start, end] = months.split('-');
+ const startMonth = getMonthName(
+ parseInt(start, 10),
+ monthStartIndexZero,
+ localeCatalog,
+ );
+ const endMonth = getMonthName(
+ parseInt(end, 10),
+ monthStartIndexZero,
+ localeCatalog,
+ );
+ return t`between ${startMonth} and ${endMonth}`;
+ }
+
+ // List values (e.g., "1,6,12")
+ if (isListValue(months)) {
+ const values = months.split(',').map((v) => v.trim());
+ const monthNames = values.map((month) => {
+ const monthNum = parseInt(month, 10);
+ return !isNaN(monthNum)
+ ? getMonthName(monthNum, monthStartIndexZero, localeCatalog)
+ : month;
+ });
+
+ if (monthNames.length === 1) {
+ const monthName = monthNames[0];
+ return t`only in ${monthName}`;
+ }
+ if (monthNames.length === 2) {
+ const firstMonth = monthNames[0];
+ const secondMonth = monthNames[1];
+ return t`only in ${firstMonth} and ${secondMonth}`;
+ }
+ const lastMonth = monthNames.pop();
+ const remainingMonths = monthNames.join(', ');
+ return t`only in ${remainingMonths} and ${lastMonth}`;
+ }
+
+ // Single month value
+ const monthNum = parseInt(months, 10);
+ if (!isNaN(monthNum)) {
+ const monthName = getMonthName(
+ monthNum,
+ monthStartIndexZero,
+ localeCatalog,
+ );
+ return t`only in ${monthName}`;
+ }
+
+ return months;
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/types/cronDescriptionOptions.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/types/cronDescriptionOptions.ts
new file mode 100644
index 00000000000..ab6f68b442d
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/types/cronDescriptionOptions.ts
@@ -0,0 +1,13 @@
+export type CronDescriptionOptions = {
+ verbose?: boolean;
+ use24HourTimeFormat?: boolean;
+ dayOfWeekStartIndexZero?: boolean;
+ monthStartIndexZero?: boolean;
+};
+
+export const DEFAULT_CRON_DESCRIPTION_OPTIONS: CronDescriptionOptions = {
+ verbose: false,
+ use24HourTimeFormat: true, // Twenty uses 24-hour format by default
+ dayOfWeekStartIndexZero: true,
+ monthStartIndexZero: false,
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/types/cronExpressionParts.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/types/cronExpressionParts.ts
new file mode 100644
index 00000000000..8ee966cb0ee
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/types/cronExpressionParts.ts
@@ -0,0 +1,18 @@
+export type CronExpressionParts = {
+ seconds: string;
+ minutes: string;
+ hours: string;
+ dayOfMonth: string;
+ month: string;
+ dayOfWeek: string;
+ year?: string;
+};
+
+export type CronFieldType =
+ | 'seconds'
+ | 'minutes'
+ | 'hours'
+ | 'dayOfMonth'
+ | 'month'
+ | 'dayOfWeek'
+ | 'year';
diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/utils/parseCronExpression.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/utils/parseCronExpression.ts
new file mode 100644
index 00000000000..8cbd2f4632b
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/utils/parseCronExpression.ts
@@ -0,0 +1,62 @@
+import { type CronExpressionParts } from '@/workflow/workflow-trigger/utils/cron-to-human/types/cronExpressionParts';
+import { CronExpressionParser } from 'cron-parser';
+import { isDefined } from 'twenty-shared/utils';
+
+export const parseCronExpression = (
+ expression: string,
+): CronExpressionParts => {
+ if (!isDefined(expression) || expression.trim() === '') {
+ throw new Error('Cron expression is required');
+ }
+
+ const parts = expression.trim().split(/\s+/);
+
+ if (parts.length < 4 || parts.length > 6) {
+ throw new Error(
+ `Invalid cron expression format. Expected 4, 5, or 6 fields, got ${parts.length}`,
+ );
+ }
+
+ try {
+ CronExpressionParser.parse(expression, { tz: 'UTC' });
+
+ // Handle different cron formats that cron-parser accepts
+ if (parts.length === 4) {
+ // Reduced format: hour day month dayOfWeek (minute defaults to 0)
+ return {
+ seconds: '0',
+ minutes: '0',
+ hours: parts[0],
+ dayOfMonth: parts[1],
+ month: parts[2],
+ dayOfWeek: parts[3],
+ };
+ } else if (parts.length === 5) {
+ // Standard format: minute hour day month dayOfWeek
+ return {
+ seconds: '0',
+ minutes: parts[0],
+ hours: parts[1],
+ dayOfMonth: parts[2],
+ month: parts[3],
+ dayOfWeek: parts[4],
+ };
+ } else if (parts.length === 6) {
+ // Extended format: second minute hour day month dayOfWeek
+ return {
+ seconds: parts[0],
+ minutes: parts[1],
+ hours: parts[2],
+ dayOfMonth: parts[3],
+ month: parts[4],
+ dayOfWeek: parts[5],
+ };
+ }
+
+ throw new Error('Unexpected error in cron expression parsing');
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : 'Unknown error';
+ throw new Error(`Invalid cron expression: ${errorMessage}`);
+ }
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/utils/validateCronField.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/utils/validateCronField.ts
new file mode 100644
index 00000000000..71600bbacb5
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/utils/cron-to-human/utils/validateCronField.ts
@@ -0,0 +1,85 @@
+import { isDefined } from 'twenty-shared/utils';
+
+import { type CronFieldType } from '../types/cronExpressionParts';
+
+type FieldRange = {
+ min: number;
+ max: number;
+};
+
+const FIELD_RANGES: Record = {
+ seconds: { min: 0, max: 59 },
+ minutes: { min: 0, max: 59 },
+ hours: { min: 0, max: 23 },
+ dayOfMonth: { min: 1, max: 31 },
+ month: { min: 1, max: 12 },
+ dayOfWeek: { min: 0, max: 7 }, // 0 and 7 both represent Sunday
+ year: { min: 1970, max: 2099 },
+};
+
+export const validateCronField = (
+ value: string,
+ fieldType: CronFieldType,
+): boolean => {
+ if (!isDefined(value) || value.trim() === '') {
+ return false;
+ }
+
+ if (value === '*') {
+ return true;
+ }
+
+ const range = FIELD_RANGES[fieldType];
+ if (!isDefined(range)) {
+ return false;
+ }
+
+ if (value.includes('/')) {
+ const [rangePart, stepPart] = value.split('/');
+ const step = parseInt(stepPart, 10);
+
+ if (isNaN(step) || step <= 0) {
+ return false;
+ }
+
+ if (rangePart === '*') {
+ return true;
+ }
+
+ return validateCronField(rangePart, fieldType);
+ }
+
+ if (value.includes('-')) {
+ const [startStr, endStr] = value.split('-');
+ const start = parseInt(startStr, 10);
+ const end = parseInt(endStr, 10);
+
+ if (isNaN(start) || isNaN(end)) {
+ return false;
+ }
+
+ return (
+ start >= range.min &&
+ start <= range.max &&
+ end >= range.min &&
+ end <= range.max &&
+ start <= end
+ );
+ }
+
+ if (value.includes(',')) {
+ const values = value.split(',');
+ return values.every((val) => validateCronField(val.trim(), fieldType));
+ }
+
+ const numValue = parseInt(value, 10);
+ if (isNaN(numValue)) {
+ return false;
+ }
+
+ if (fieldType === 'dayOfWeek' && (numValue === 0 || numValue === 7)) {
+ return true;
+ }
+
+ return numValue >= range.min && numValue <= range.max;
+};
diff --git a/packages/twenty-front/src/utils/format/__tests__/formatTime.test.ts b/packages/twenty-front/src/utils/format/__tests__/formatTime.test.ts
new file mode 100644
index 00000000000..26c3ea504a3
--- /dev/null
+++ b/packages/twenty-front/src/utils/format/__tests__/formatTime.test.ts
@@ -0,0 +1,24 @@
+import { formatTime } from '../formatTime';
+
+describe('formatTime', () => {
+ it('should format 24-hour time', () => {
+ expect(formatTime('9', '30', true)).toBe('09:30');
+ expect(formatTime('14', '0', true)).toBe('14:00');
+ expect(formatTime('0', '0', true)).toBe('00:00');
+ expect(formatTime('23', '59', true)).toBe('23:59');
+ });
+
+ it('should format 12-hour time', () => {
+ expect(formatTime('9', '30', false)).toBe('9:30 AM');
+ expect(formatTime('14', '0', false)).toBe('2:00 PM');
+ expect(formatTime('0', '0', false)).toBe('12:00 AM');
+ expect(formatTime('12', '0', false)).toBe('12:00 PM');
+ expect(formatTime('23', '59', false)).toBe('11:59 PM');
+ });
+
+ it('should handle invalid inputs', () => {
+ expect(formatTime('invalid', '30', true)).toBe('');
+ expect(formatTime('9', 'invalid', true)).toBe('');
+ expect(formatTime('', '', true)).toBe('');
+ });
+});
diff --git a/packages/twenty-front/src/utils/format/__tests__/getOrdinalNumber.test.ts b/packages/twenty-front/src/utils/format/__tests__/getOrdinalNumber.test.ts
new file mode 100644
index 00000000000..4f44f1cfdc0
--- /dev/null
+++ b/packages/twenty-front/src/utils/format/__tests__/getOrdinalNumber.test.ts
@@ -0,0 +1,14 @@
+import { getOrdinalNumber } from '../getOrdinalNumber';
+
+describe('getOrdinalNumber', () => {
+ it('should return correct ordinal numbers', () => {
+ expect(getOrdinalNumber(1)).toBe('1st');
+ expect(getOrdinalNumber(2)).toBe('2nd');
+ expect(getOrdinalNumber(3)).toBe('3rd');
+ expect(getOrdinalNumber(4)).toBe('4th');
+ expect(getOrdinalNumber(11)).toBe('11th');
+ expect(getOrdinalNumber(21)).toBe('21st');
+ expect(getOrdinalNumber(22)).toBe('22nd');
+ expect(getOrdinalNumber(23)).toBe('23rd');
+ });
+});
diff --git a/packages/twenty-front/src/utils/format/formatTime.ts b/packages/twenty-front/src/utils/format/formatTime.ts
new file mode 100644
index 00000000000..898c0012ede
--- /dev/null
+++ b/packages/twenty-front/src/utils/format/formatTime.ts
@@ -0,0 +1,27 @@
+import { isDefined } from 'twenty-shared/utils';
+
+export const formatTime = (
+ hour: string,
+ minute: string,
+ use24HourFormat: boolean,
+): string => {
+ if (!isDefined(hour) || !isDefined(minute)) {
+ return '';
+ }
+
+ const hourNum = parseInt(hour, 10);
+ const minuteNum = parseInt(minute, 10);
+
+ if (isNaN(hourNum) || isNaN(minuteNum)) {
+ return '';
+ }
+
+ if (use24HourFormat) {
+ return `${hourNum.toString().padStart(2, '0')}:${minuteNum.toString().padStart(2, '0')}`;
+ } else {
+ const period = hourNum >= 12 ? 'PM' : 'AM';
+ const displayHour =
+ hourNum === 0 ? 12 : hourNum > 12 ? hourNum - 12 : hourNum;
+ return `${displayHour}:${minuteNum.toString().padStart(2, '0')} ${period}`;
+ }
+};
diff --git a/packages/twenty-front/src/utils/format/getOrdinalNumber.ts b/packages/twenty-front/src/utils/format/getOrdinalNumber.ts
new file mode 100644
index 00000000000..65e6819cb1a
--- /dev/null
+++ b/packages/twenty-front/src/utils/format/getOrdinalNumber.ts
@@ -0,0 +1,31 @@
+import { selectOrdinal } from '@lingui/core/macro';
+
+export const getOrdinalNumber = (num: number): string => {
+ try {
+ return selectOrdinal(num, {
+ one: `${num}st`,
+ two: `${num}nd`,
+ few: `${num}rd`,
+ other: `${num}th`,
+ });
+ } catch {
+ // Fallback for test environment - basic English ordinals
+ const lastDigit = num % 10;
+ const lastTwoDigits = num % 100;
+
+ if (lastTwoDigits >= 11 && lastTwoDigits <= 13) {
+ return `${num}th`;
+ }
+
+ switch (lastDigit) {
+ case 1:
+ return `${num}st`;
+ case 2:
+ return `${num}nd`;
+ case 3:
+ return `${num}rd`;
+ default:
+ return `${num}th`;
+ }
+ }
+};
diff --git a/packages/twenty-front/src/utils/validation/__tests__/isListValue.test.ts b/packages/twenty-front/src/utils/validation/__tests__/isListValue.test.ts
new file mode 100644
index 00000000000..476cf427788
--- /dev/null
+++ b/packages/twenty-front/src/utils/validation/__tests__/isListValue.test.ts
@@ -0,0 +1,16 @@
+import { isListValue } from '../isListValue';
+
+describe('isListValue', () => {
+ it('should detect list values', () => {
+ expect(isListValue('1,2,3')).toBe(true);
+ expect(isListValue('0,15,30,45')).toBe(true);
+ expect(isListValue('1,5')).toBe(true);
+ });
+
+ it('should reject non-list values', () => {
+ expect(isListValue('*')).toBe(false);
+ expect(isListValue('*/5')).toBe(false);
+ expect(isListValue('1-5')).toBe(false);
+ expect(isListValue('15')).toBe(false);
+ });
+});
diff --git a/packages/twenty-front/src/utils/validation/__tests__/isNumericRange.test.ts b/packages/twenty-front/src/utils/validation/__tests__/isNumericRange.test.ts
new file mode 100644
index 00000000000..9381efb5658
--- /dev/null
+++ b/packages/twenty-front/src/utils/validation/__tests__/isNumericRange.test.ts
@@ -0,0 +1,21 @@
+import { isNumericRange } from '../isNumericRange';
+
+describe('isNumericRange', () => {
+ it('should detect numeric ranges', () => {
+ expect(isNumericRange('1-5')).toBe(true);
+ expect(isNumericRange('10-20')).toBe(true);
+ expect(isNumericRange('0-59')).toBe(true);
+ });
+
+ it('should detect single numbers', () => {
+ expect(isNumericRange('15')).toBe(true);
+ expect(isNumericRange('0')).toBe(true);
+ });
+
+ it('should reject non-numeric ranges', () => {
+ expect(isNumericRange('*')).toBe(false);
+ expect(isNumericRange('*/5')).toBe(false);
+ expect(isNumericRange('1,2,3')).toBe(false);
+ expect(isNumericRange('invalid')).toBe(false);
+ });
+});
diff --git a/packages/twenty-front/src/utils/validation/__tests__/isStepValue.test.ts b/packages/twenty-front/src/utils/validation/__tests__/isStepValue.test.ts
new file mode 100644
index 00000000000..158c4cdd1e1
--- /dev/null
+++ b/packages/twenty-front/src/utils/validation/__tests__/isStepValue.test.ts
@@ -0,0 +1,16 @@
+import { isStepValue } from '../isStepValue';
+
+describe('isStepValue', () => {
+ it('should detect step values', () => {
+ expect(isStepValue('*/5')).toBe(true);
+ expect(isStepValue('1-10/2')).toBe(true);
+ expect(isStepValue('0-59/15')).toBe(true);
+ });
+
+ it('should reject non-step values', () => {
+ expect(isStepValue('*')).toBe(false);
+ expect(isStepValue('1-5')).toBe(false);
+ expect(isStepValue('1,2,3')).toBe(false);
+ expect(isStepValue('15')).toBe(false);
+ });
+});
diff --git a/packages/twenty-front/src/utils/validation/isListValue.ts b/packages/twenty-front/src/utils/validation/isListValue.ts
new file mode 100644
index 00000000000..6ecf613059c
--- /dev/null
+++ b/packages/twenty-front/src/utils/validation/isListValue.ts
@@ -0,0 +1,5 @@
+import { isDefined } from 'twenty-shared/utils';
+
+export const isListValue = (value: string): boolean => {
+ return isDefined(value) && value.includes(',');
+};
diff --git a/packages/twenty-front/src/utils/validation/isNumericRange.ts b/packages/twenty-front/src/utils/validation/isNumericRange.ts
new file mode 100644
index 00000000000..49d45dd11b5
--- /dev/null
+++ b/packages/twenty-front/src/utils/validation/isNumericRange.ts
@@ -0,0 +1,5 @@
+import { isDefined } from 'twenty-shared/utils';
+
+export const isNumericRange = (value: string): boolean => {
+ return isDefined(value) && /^\d+(-\d+)?$/.test(value);
+};
diff --git a/packages/twenty-front/src/utils/validation/isStepValue.ts b/packages/twenty-front/src/utils/validation/isStepValue.ts
new file mode 100644
index 00000000000..b348907ea1e
--- /dev/null
+++ b/packages/twenty-front/src/utils/validation/isStepValue.ts
@@ -0,0 +1,5 @@
+import { isDefined } from 'twenty-shared/utils';
+
+export const isStepValue = (value: string): boolean => {
+ return isDefined(value) && value.includes('/');
+};
diff --git a/packages/twenty-server/package.json b/packages/twenty-server/package.json
index 765c5215300..08586b05b0b 100644
--- a/packages/twenty-server/package.json
+++ b/packages/twenty-server/package.json
@@ -96,7 +96,6 @@
"cloudflare": "^4.5.0",
"connect-redis": "^7.1.1",
"cron-parser": "5.1.1",
- "cron-validate": "1.4.5",
"dataloader": "2.2.2",
"date-fns": "2.30.0",
"deep-equal": "2.2.3",
diff --git a/packages/twenty-server/src/modules/workflow/workflow-trigger/utils/__tests__/compute-cron-pattern-from-schedule.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-trigger/utils/__tests__/compute-cron-pattern-from-schedule.spec.ts
index f7d01a5ce37..831197812cd 100644
--- a/packages/twenty-server/src/modules/workflow/workflow-trigger/utils/__tests__/compute-cron-pattern-from-schedule.spec.ts
+++ b/packages/twenty-server/src/modules/workflow/workflow-trigger/utils/__tests__/compute-cron-pattern-from-schedule.spec.ts
@@ -1,8 +1,8 @@
+import { WorkflowTriggerException } from 'src/modules/workflow/workflow-trigger/exceptions/workflow-trigger.exception';
import {
type WorkflowCronTrigger,
WorkflowTriggerType,
} from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
-import { WorkflowTriggerException } from 'src/modules/workflow/workflow-trigger/exceptions/workflow-trigger.exception';
import { computeCronPatternFromSchedule } from 'src/modules/workflow/workflow-trigger/utils/compute-cron-pattern-from-schedule';
describe('computeCronPatternFromSchedule', () => {
@@ -20,7 +20,7 @@ describe('computeCronPatternFromSchedule', () => {
expect(computeCronPatternFromSchedule(trigger)).toBe('12 * * * *');
});
- it('should throw an exception for unsupported pattern for CUSTOM type', () => {
+ it('should support 6-field cron patterns with seconds for CUSTOM type', () => {
const trigger: WorkflowCronTrigger = {
name: '',
type: WorkflowTriggerType.CRON,
@@ -31,11 +31,25 @@ describe('computeCronPatternFromSchedule', () => {
},
};
+ expect(computeCronPatternFromSchedule(trigger)).toBe('0 12 * * * *');
+ });
+
+ it('should throw an exception for invalid pattern for CUSTOM type', () => {
+ const trigger: WorkflowCronTrigger = {
+ name: '',
+ type: WorkflowTriggerType.CRON,
+ settings: {
+ type: 'CUSTOM',
+ pattern: '60 25 32 13 8',
+ outputSchema: {},
+ },
+ };
+
expect(() => computeCronPatternFromSchedule(trigger)).toThrow(
WorkflowTriggerException,
);
expect(() => computeCronPatternFromSchedule(trigger)).toThrow(
- "Cron pattern '0 12 * * * *' is invalid",
+ "Cron pattern '60 25 32 13 8' is invalid",
);
});
diff --git a/packages/twenty-server/src/modules/workflow/workflow-trigger/utils/compute-cron-pattern-from-schedule.ts b/packages/twenty-server/src/modules/workflow/workflow-trigger/utils/compute-cron-pattern-from-schedule.ts
index 42b28753f3a..3c9ea34a633 100644
--- a/packages/twenty-server/src/modules/workflow/workflow-trigger/utils/compute-cron-pattern-from-schedule.ts
+++ b/packages/twenty-server/src/modules/workflow/workflow-trigger/utils/compute-cron-pattern-from-schedule.ts
@@ -1,5 +1,5 @@
import { t } from '@lingui/core/macro';
-import cron from 'cron-validate';
+import { CronExpressionParser } from 'cron-parser';
import {
WorkflowTriggerException,
@@ -8,11 +8,11 @@ import {
import { type WorkflowCronTrigger } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
const validatePattern = (pattern: string) => {
- const cronValidator = cron(pattern);
-
- if (cronValidator.isError()) {
+ try {
+ CronExpressionParser.parse(pattern);
+ } catch (error) {
throw new WorkflowTriggerException(
- `Cron pattern '${pattern}' is invalid`,
+ `Cron pattern '${pattern}' is invalid: ${error.message}`,
WorkflowTriggerExceptionCode.INVALID_WORKFLOW_TRIGGER,
{
userFriendlyMessage: t`Cron pattern '${pattern}' is invalid`,
diff --git a/yarn.lock b/yarn.lock
index dae40335434..4d742f49098 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3465,7 +3465,7 @@ __metadata:
languageName: node
linkType: hard
-"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.10.5, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.22.6, @babel/runtime@npm:^7.23.8, @babel/runtime@npm:^7.24.1, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.9.2":
+"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.22.6, @babel/runtime@npm:^7.23.8, @babel/runtime@npm:^7.24.1, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.9.2":
version: 7.26.7
resolution: "@babel/runtime@npm:7.26.7"
dependencies:
@@ -21555,7 +21555,7 @@ __metadata:
languageName: node
linkType: hard
-"@types/lodash@npm:*, @types/lodash@npm:^4.14.149, @types/lodash@npm:^4.14.165, @types/lodash@npm:^4.14.175":
+"@types/lodash@npm:*, @types/lodash@npm:^4.14.149, @types/lodash@npm:^4.14.175":
version: 4.17.15
resolution: "@types/lodash@npm:4.17.15"
checksum: 10c0/2eb2dc6d231f5fb4603d176c08c8d7af688f574d09af47466a179cd7812d9f64144ba74bb32ca014570ffdc544eedc51b7a5657212bad083b6eecbd72223f9bb
@@ -28937,15 +28937,6 @@ __metadata:
languageName: node
linkType: hard
-"cron-validate@npm:1.4.5":
- version: 1.4.5
- resolution: "cron-validate@npm:1.4.5"
- dependencies:
- yup: "npm:0.32.9"
- checksum: 10c0/4c210bea21832269fd8e18e4c9e9d750b314da04bc475b8b58414a6421a4c86804a6f1a8b9eda6d357af4c43f627fa3ee372f6fbb99b849d2a9b2aef2917bddf
- languageName: node
- linkType: hard
-
"cron@npm:2.4.3":
version: 2.4.3
resolution: "cron@npm:2.4.3"
@@ -39800,7 +39791,7 @@ __metadata:
languageName: node
linkType: hard
-"lodash-es@npm:^4.17.15, lodash-es@npm:^4.17.21":
+"lodash-es@npm:^4.17.21":
version: 4.17.21
resolution: "lodash-es@npm:4.17.21"
checksum: 10c0/fb407355f7e6cd523a9383e76e6b455321f0f153a6c9625e21a8827d10c54c2a2341bd2ae8d034358b60e07325e1330c14c224ff582d04612a46a4f0479ff2f2
@@ -52460,6 +52451,7 @@ __metadata:
apollo-link-rest: "npm:^0.9.0"
apollo-upload-client: "npm:^17.0.0"
buffer: "npm:^6.0.3"
+ cron-parser: "npm:5.1.1"
date-fns: "npm:^2.30.0"
docx: "npm:^9.1.0"
eslint: "npm:^9.32.0"
@@ -52637,7 +52629,6 @@ __metadata:
cloudflare: "npm:^4.5.0"
connect-redis: "npm:^7.1.1"
cron-parser: "npm:5.1.1"
- cron-validate: "npm:1.4.5"
dataloader: "npm:2.2.2"
date-fns: "npm:2.30.0"
deep-equal: "npm:2.2.3"
@@ -56073,21 +56064,6 @@ __metadata:
languageName: node
linkType: hard
-"yup@npm:0.32.9":
- version: 0.32.9
- resolution: "yup@npm:0.32.9"
- dependencies:
- "@babel/runtime": "npm:^7.10.5"
- "@types/lodash": "npm:^4.14.165"
- lodash: "npm:^4.17.20"
- lodash-es: "npm:^4.17.15"
- nanoclone: "npm:^0.2.1"
- property-expr: "npm:^2.0.4"
- toposort: "npm:^2.0.2"
- checksum: 10c0/b2adff31f4be85aaad338e6db12a26715b9e11270c587afe051d42c423f7f24de2d184f646047cb5c3b8c65163c37611f8309f2ef4eb6bb7a66688158a081d66
- languageName: node
- linkType: hard
-
"yup@npm:^0.32.0":
version: 0.32.11
resolution: "yup@npm:0.32.11"