Jobs Administration – Next Run Date


Prerequisites:

- Familiarity with Page Parameter Filter blocks

- Familiarity with Dynamic Data blocks

- Familiarity with HTML Content blocks and Lava

- Comfort with T-SQL

The Summary

Provides a comprehensive view of one or more service jobs, extending the standard Jobs Administration Service Job List with a calculated next run date. Results can be filtered by one or more Job IDs, Active/Inactive status, and a configurable lookahead window expressed in future days.


The Why

Rock's core Jobs Administration page shows Last Successful Run Date and Last Status, but does not display a calculated next run date. For organizations that need to monitor when jobs are scheduled to run next — especially for troubleshooting, auditing, or planning maintenance windows — this recipe fills that gap.

This solution supports all 7-part Quartz cron expressions used by Rock, including all special characters:

| Character | Purpose |

|-----------|---------|

| `*` | Any value |

| `/` | Step — e.g. `*/10` or `1/10` |

| `-` | Range — e.g. `MON-FRI` or `6-16` |

| `,` | List — e.g. `12,20` or `MON,TUE` |

| `?` | No specific value (Day of Month or Day of Week) |

| `L` | Last day of month or last xxx day of month |

| `W` | Nearest weekday to a given day |

| `#` | Nth occurrence — e.g. `MON#1`, `THU#5` |

> Note: Per the cron specification, if an Nth occurrence does not exist in the current month (e.g. `THU#5` when May only has 4 Thursdays), no firing occurs that month and the Next Run Date will be NULL for that job.

---

The How

This recipe uses three pages and four blocks:

  • Page 1 — Criteria Page: Page Parameter Filter block + HTML Content block
  • Page 2 — Results Page: Dynamic Data block


Step 1: Create the Criteria Page

Create a new internal page with a **Full Width** layout. This page will host the filter criteria.

Add the following blocks to the page in order:

  1. Page Parameter Filter block
  2. HTML Content block


Step 2: Configure the Page Parameter Filter Block

Add three filters to the Page Parameter Filter block:


Filter 1 — Job Ids (Multi-Select)

*Job Ids filter: Multi-Select field type, populated via SQL query from ServiceJob table.*

| Setting | Value |

|---------|-------|

| Name | Job Ids |

| Abbreviated Name | Job Ids |

| Key | JobIds |

| Field Type | Multi-Select |

| Enhance For Long Lists | ✅ Checked |

Values (SQL):

SELECT [Id] AS [Value], [Name] + ' (' + CASE WHEN [IsActive] = 1 THEN 'Active' ELSE 'Inactive' END + ')' AS [TEXT]

FROM [dbo].[ServiceJob]

ORDER BY [Name]


Filter 2 — Next Run Within (Single-Select Drop Down)

*Next Run Within filter: Single-Select Drop Down with minute values mapped to friendly day labels.*

| Setting | Value |

|---------|-------|

| Name | Next Run Within |

| Abbreviated Name | Next Run Within |

| Key | NextRunWithin |

| Field Type | Single-Select |

| Control Type | Drop Down List |

Values:


1440^24 Hrs, 10080^7 Days, 20160^14 Days, 44640^31 Days, 86400^60 Days, 129600^90 Days, 259200^180 Days, 527040^366 Days


Note: Values are in minutes (the unit used by the query). Labels are the friendly display shown to the user.

Filter 3 — Active Jobs (Boolean Drop Down)

*Active Jobs filter: Boolean field type rendered as a Drop Down with True/False options.*

| Setting | Value |

|---------|-------|

| Name | Active Jobs |

| Abbreviated Name | Active Jobs |

| Key | ActiveJobs |

| Field Type | Boolean |

| Control Type | Drop Down |

| True Text | True |

| False Text | False |

---

Step 3: Configure the HTML Content Block

The HTML Content block reads the selected filter values from the page parameters, validates that all required fields have been selected, and generates a URL that passes those parameters to the Dynamic Data results page.

Add the following Lava to the HTML Content block:

{{ Lava }}

{% assign jobIds = 'Global' | PageParameter:'JobIds' %}

{% assign activeJobs = 'Global' | PageParameter:'ActiveJobs' | SanitizeSql%}

{% assign nextRunWithin = 'Global' | PageParameter:'NextRunWithin' | SanitizeSql %}



{% assign urlRedirect = 'Global' | Attribute:'InternalApplicationRoot' | Append:'/page/2381?' | Append:'jobIds=' %}


{% if jobIds != null or jobIds != empty %}

    {% assign urlRedirect = urlRedirect | Append:jobIds %}

{% endif %}

{% if activeJobs != null or activeJobs != empty %}

    {% assign urlRedirect = urlRedirect | Append:'&activeJobs=' | Append:activeJobs %}

{% endif %}

{% if nextRunWithin != null or nextRunWithin != empty %}

    {% assign urlRedirect = urlRedirect | Append:'&nextRunWithin=' | Append:nextRunWithin %}

{% endif %}


{% if activeJobs != null and activeJobs != empty and nextRunWithin != null and nextRunWithin != empty and jobIds != null and jobIds != empty %}

    //- {% if CurrentPersonCanEdit %}

   <a class="btn btn-primary" href="{{ urlRedirect }}">Generate</a>

   /- {{ urlRedirect | PageRedirect }}

    {% endif %} -/

{% else %}

<div class="alert alert-info">

   Please select an option for all parameters to generate the export

</div>

{% endif %}


Step 4: Create the Results Page

Create a second internal page with a Full Width layout. Add a single Dynamic Data block to this page.


Step 5: Configure the Dynamic Data Block

Paste the following SQL query into the Dynamic Data block's Query field.

exec [_org_second_sp_Cron_Next_Run_select] @JobIds='{{jobIds}}', @varIsActive='{{activeJobs}}', @varMinutes='{{nextRunWithin}}'

Block Settings:

  • Query Parameters: @JobIds='{{jobIds}}', @varIsActive='{{activeJobs}}', @varMinutes='{{nextRunWithin}}'
  • Enable Export: ✅ Checked
  • Timeout: 300 seconds (increase if needed for large lookahead windows)
  • Selection URL: ~/admin/system/jobs/{JobId}

Notes & Limitations

  • Timezone: Next run times are calculated in Central Standard Time (CST). To change the timezone, replace `'Central Standard Time'` in the `CronBase` CTE with your desired timezone name (e.g. `'Eastern Standard Time'`).
  • Nth Occurrence (# character): If the Nth occurrence of a day does not exist in the current month (e.g. `THU#5` when there are only 4 Thursdays), the Next Run column will be NULL for that job. This is correct per the cron specification.
  • SQL Server Version: Tested on SQL Server 2014. Uses `EOMONTH()` which requires SQL Server 2012 or later.
  • Performance: Larger lookahead windows generate more candidate rows. For 366 days across all jobs, query time will increase. Use the Job Ids filter to narrow results when using large windows.
  • sys.objects row count: See the Numbers CTE note in the query header if you encounter row count issues with large lookahead windows.

Dependencies

This recipe requires custom string-splitting UDF (user defined function) to parse cron expression fields by index and multi select job parameter. The query references helper functions:


  • [dbo].[ufn_org_second_fn_SplitsByIndex](delimiter, string, index) - This function takes a delimiter, a string, and a 1-based index and returns the Nth delimited segment.
  • [dbo].[ufn_org_second_fn_Split](string, delimiter) -- returns [Items] - This function takes a string and a delimiter and returns [Items] in a table to select from. 


ufn_org_second_fn_Split

/****** Object: UserDefinedFunction [dbo].[ufn_org_second_fn_Split] Script Date: 5/27/2026 6:02:54 PM ******/ SET ANSI_NULLS ON GO SET QUOTED_IDENTIFIER ON GO CREATE FUNCTION [dbo].[ufn_org_second_fn_Split](@String varchar(max), @Delimiter varchar(50)) returns @temptable TABLE (Items varchar(1700) INDEX IX2 NONCLUSTERED) as begin declare @idx int declare @slice varchar(max) select @idx = 1 if len(@String)<1 or @String is null return while @idx!= 0 begin set @idx = charindex(@Delimiter,@String) if @idx!=0 set @slice = left(@String,@idx - 1) else set @slice = @String if(len(@slice)>0) insert into @temptable(Items) values(@slice) set @String = right(@String,len(@String) - @idx) if len(@String) = 0 break end return end GO

ufn_org_second_fn_SplitsByIndex

/****** Object: UserDefinedFunction [dbo].[ufn_org_second_fn_SplitsByIndex] Script Date: 5/27/2026 6:01:47 PM ******/ SET ANSI_NULLS ON GO SET QUOTED_IDENTIFIER ON GO CREATE FUNCTION [dbo].[ufn_org_second_fn_SplitsByIndex] (@separator VARCHAR(20) = ' ', @string VARCHAR(MAX), @position INT ) RETURNS VARCHAR(MAX) AS BEGIN DECLARE @results TABLE (id INT IDENTITY(1, 1), chrs VARCHAR(8000) ); DECLARE @outResult VARCHAR(8000); WITH X(N) AS (SELECT 'Table1' FROM(VALUES(0), (0), (0), (0), (0), (0), (0), (0), (0), (0), (0), (0), (0), (0), (0), (0)) T(C)), Y(N) AS (SELECT 'Table2' FROM X A1, X A2, X A3, X A4, X A5, X A6, X A7, X A8), -- Up to 16^8 = 4 billion T(N) AS (SELECT TOP (ISNULL(LEN(@string), 0)) ROW_NUMBER() OVER( ORDER BY ( SELECT NULL )) - 1 N FROM Y), Delim(Pos) AS (SELECT t.N FROM T WHERE(SUBSTRING(@string, t.N, LEN(@separator + 'x') - 1) LIKE @separator OR t.N = 0)), Separated(value) AS (SELECT SUBSTRING(@string, d.Pos + LEN(@separator + 'x') - 1, LEAD(d.Pos, 1, 2147483647) OVER( ORDER BY ( SELECT NULL ))-d.Pos - LEN(@separator)) FROM Delim d WHERE @string IS NOT NULL) INSERT INTO @results(chrs) SELECT s.value FROM Separated s WHERE s.value <> @separator; SELECT @outResult = ( SELECT chrs FROM @results WHERE id = @position ); RETURN @outResult; END; GO