diff --git a/CHANGELOG.md b/CHANGELOG.md
index b2baa0fba31..2357d2af945 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,8 @@ and this project adheres to
### Changed
+- Cron schedules now show "UTC" after the time, on the canvas and in the trigger
+ panels [#5247](https://github.com/OpenFn/lightning/issues/5247)
- Update Project and Workflow yaml exports to match the v4 portability spec
[#4718](https://github.com/OpenFn/lightning/issues/4718)
- The workflow version hash no longer includes a trigger's `enabled` state
diff --git a/assets/js/collaborative-editor/components/inspector/trigger/CronConfigureStep.tsx b/assets/js/collaborative-editor/components/inspector/trigger/CronConfigureStep.tsx
index b03d48a9eb0..ce2a30c298e 100644
--- a/assets/js/collaborative-editor/components/inspector/trigger/CronConfigureStep.tsx
+++ b/assets/js/collaborative-editor/components/inspector/trigger/CronConfigureStep.tsx
@@ -71,9 +71,13 @@ export function CronConfigureStep({
/>
-
- When should this run?
-
+ {/* Cron schedules always run in UTC, whatever the viewer's zone. */}
+
+
+ When should this run?
+
+ All times in UTC
+
mergeDraft({ cron_expression: expr })}
diff --git a/assets/js/collaborative-editor/components/inspector/trigger/CronFieldBuilder.tsx b/assets/js/collaborative-editor/components/inspector/trigger/CronFieldBuilder.tsx
index bcf8d41a419..7b3f006e6b8 100644
--- a/assets/js/collaborative-editor/components/inspector/trigger/CronFieldBuilder.tsx
+++ b/assets/js/collaborative-editor/components/inspector/trigger/CronFieldBuilder.tsx
@@ -1,8 +1,8 @@
import { useState, useEffect, useMemo } from 'react';
-import { cn } from '../../../../utils/cn';
+import { humanizeCron } from '#/utils/humanizeCron';
-import { humanizeCron } from './cronSchedule';
+import { cn } from '../../../../utils/cn';
interface CronFieldBuilderProps {
value: string;
diff --git a/assets/js/collaborative-editor/components/inspector/trigger/CronShowPanel.tsx b/assets/js/collaborative-editor/components/inspector/trigger/CronShowPanel.tsx
index bf7f717304f..5f62673c051 100644
--- a/assets/js/collaborative-editor/components/inspector/trigger/CronShowPanel.tsx
+++ b/assets/js/collaborative-editor/components/inspector/trigger/CronShowPanel.tsx
@@ -1,8 +1,9 @@
+import { humanizeCron } from '#/utils/humanizeCron';
+
import { useWorkflowState } from '../../../hooks/useWorkflow';
import type { Workflow } from '../../../types/workflow';
import { InspectorLayout } from '../InspectorLayout';
-import { humanizeCron } from './cronSchedule';
import { EditFooter } from './EditFooter';
import { ReadOnlyField } from './ReadOnlyField';
import { TriggerTypeBadge } from './TriggerTypeBadge';
diff --git a/assets/js/collaborative-editor/components/inspector/trigger/cronSchedule.ts b/assets/js/collaborative-editor/components/inspector/trigger/cronSchedule.ts
deleted file mode 100644
index 606c4554c1c..00000000000
--- a/assets/js/collaborative-editor/components/inspector/trigger/cronSchedule.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-/**
- * Cron humanization helper for the cron trigger inspector.
- *
- * `humanizeCron` turns a stored cron expression into friendly text (via
- * `cronstrue`) for the read-only show panel. Authoring the schedule itself is
- * handled by the dropdown-based {@link CronFieldBuilder}, which does its own
- * cron parsing/serialization, so no natural-language parser lives here.
- */
-
-import cronstrue from 'cronstrue';
-
-/**
- * Humanize a cron expression for display. Returns `null` if cronstrue throws
- * (e.g. on a malformed expression).
- */
-export function humanizeCron(expr: string): string | null {
- try {
- return cronstrue.toString(expr);
- } catch {
- return null;
- }
-}
diff --git a/assets/js/utils/humanizeCron.ts b/assets/js/utils/humanizeCron.ts
new file mode 100644
index 00000000000..efb86125c4a
--- /dev/null
+++ b/assets/js/utils/humanizeCron.ts
@@ -0,0 +1,29 @@
+/**
+ * Cron humanization helper for the cron trigger inspector and diagram node.
+ *
+ * `humanizeCron` turns a stored cron expression into friendly text (via
+ * `cronstrue`) for the show panel, the builder preview and the diagram node.
+ * Authoring the schedule itself is handled by the dropdown-based
+ * {@link CronFieldBuilder}, which does its own cron parsing/serialization, so
+ * no natural-language parser lives here.
+ */
+
+import cronstrue from 'cronstrue';
+
+const LAST_CLOCK_TIME = /(\d{2}:\d{2} [AP]M)(?!.*\d{2}:\d{2} [AP]M)/;
+
+/**
+ * Humanize a cron expression for display. Returns `null` if cronstrue throws
+ * (e.g. on a malformed expression).
+ *
+ * Cron schedules run in UTC, so "UTC" follows the last clock time in the text
+ * ("At 09:00 AM UTC, Monday through Friday"). It sits next to the time, not at
+ * the end, so a clamped label (the diagram node) cuts the days, not the zone.
+ */
+export function humanizeCron(expr: string): string | null {
+ try {
+ return cronstrue.toString(expr).replace(LAST_CLOCK_TIME, '$1 UTC');
+ } catch {
+ return null;
+ }
+}
diff --git a/assets/js/workflow-diagram/nodes/Trigger.tsx b/assets/js/workflow-diagram/nodes/Trigger.tsx
index 7be04b03e78..f5c48a49a7e 100644
--- a/assets/js/workflow-diagram/nodes/Trigger.tsx
+++ b/assets/js/workflow-diagram/nodes/Trigger.tsx
@@ -1,8 +1,9 @@
import { ClockIcon, GlobeAltIcon } from '@heroicons/react/24/outline';
import { Position } from '@xyflow/react';
-import cronstrue from 'cronstrue';
import { memo } from 'react';
+import { humanizeCron } from '#/utils/humanizeCron';
+
import { lockClosedIcon } from '../components/trigger-icons';
import type { Lightning } from '../types';
@@ -62,17 +63,11 @@ function getTriggerMeta(trigger: Lightning.TriggerNode): TriggerMeta {
secondaryIcon: trigger.has_auth_method ? lockClosedIcon : null,
};
case 'cron':
- let sublabel = 'Not configured';
- try {
- if (trigger.cron_expression) {
- sublabel = cronstrue.toString(trigger.cron_expression);
- }
- } catch (_error) {
- sublabel = 'Invalid cron expression';
- }
return {
label: 'Cron trigger',
- sublabel,
+ sublabel: trigger.cron_expression
+ ? (humanizeCron(trigger.cron_expression) ?? 'Invalid cron expression')
+ : 'Not configured',
primaryIcon: ,
secondaryIcon: null,
};
diff --git a/assets/test/collaborative-editor/components/inspector/trigger/CronFieldBuilder.test.tsx b/assets/test/collaborative-editor/components/inspector/trigger/CronFieldBuilder.test.tsx
index 092da532dea..2dc9a313ab7 100644
--- a/assets/test/collaborative-editor/components/inspector/trigger/CronFieldBuilder.test.tsx
+++ b/assets/test/collaborative-editor/components/inspector/trigger/CronFieldBuilder.test.tsx
@@ -485,7 +485,7 @@ describe('CronFieldBuilder - Advanced Section', () => {
// cronstrue humanizes "0 9 * * 1-5".
expect(
- screen.getByText('At 09:00 AM, Monday through Friday')
+ screen.getByText('At 09:00 AM UTC, Monday through Friday')
).toBeInTheDocument();
});
diff --git a/assets/test/collaborative-editor/components/inspector/trigger/CronShowPanel.test.tsx b/assets/test/collaborative-editor/components/inspector/trigger/CronShowPanel.test.tsx
index 6ff9cf894ef..a5ca5f7968b 100644
--- a/assets/test/collaborative-editor/components/inspector/trigger/CronShowPanel.test.tsx
+++ b/assets/test/collaborative-editor/components/inspector/trigger/CronShowPanel.test.tsx
@@ -111,7 +111,7 @@ describe('CronShowPanel', () => {
expect(screen.getByText('Schedule / Cron')).toBeInTheDocument();
expect(screen.getByText('Frequency')).toBeInTheDocument();
- expect(screen.getByText('At 09:00 AM')).toBeInTheDocument();
+ expect(screen.getByText('At 09:00 AM UTC')).toBeInTheDocument();
});
describe('edit button gating', () => {
diff --git a/assets/test/collaborative-editor/components/inspector/trigger/TriggerEditWizard.test.tsx b/assets/test/collaborative-editor/components/inspector/trigger/TriggerEditWizard.test.tsx
index 7fd9416b5b5..720ad6d284b 100644
--- a/assets/test/collaborative-editor/components/inspector/trigger/TriggerEditWizard.test.tsx
+++ b/assets/test/collaborative-editor/components/inspector/trigger/TriggerEditWizard.test.tsx
@@ -422,6 +422,7 @@ describe('TriggerEditWizard — cron', () => {
// Next → Configure (Configure-only control present).
await userEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(screen.getByLabelText('Cron Input Source')).toBeInTheDocument();
+ expect(screen.getByText('All times in UTC')).toBeInTheDocument();
// Breadcrumb "Choose" crumb returns to Choose.
await userEvent.click(screen.getByRole('button', { name: 'Choose' }));
diff --git a/assets/test/collaborative-editor/components/inspector/trigger/cronSchedule.test.ts b/assets/test/collaborative-editor/components/inspector/trigger/cronSchedule.test.ts
deleted file mode 100644
index 07b2c8a280b..00000000000
--- a/assets/test/collaborative-editor/components/inspector/trigger/cronSchedule.test.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-/**
- * cronSchedule utility tests
- *
- * Covers `humanizeCron` — the cron → friendly text helper used by the cron
- * show panel. (Schedule authoring is handled by the CronFieldBuilder dropdown,
- * which does its own parsing, so there is no natural-language parser to test.)
- */
-
-import { describe, expect, test } from 'vitest';
-
-import { humanizeCron } from '../../../../../js/collaborative-editor/components/inspector/trigger/cronSchedule';
-
-describe('humanizeCron', () => {
- test.each<[string, string, string | null]>([
- ['daily at 9 AM', '0 9 * * *', 'At 09:00 AM'],
- ['every 15 minutes', '*/15 * * * *', 'Every 15 minutes'],
- ['weekdays at 9 AM', '0 9 * * 1-5', 'At 09:00 AM, Monday through Friday'],
- [
- 'monthly on the 15th',
- '30 9 15 * *',
- 'At 09:30 AM, on day 15 of the month',
- ],
- ['weekly on Sunday midnight', '0 0 * * 0', 'At 12:00 AM, only on Sunday'],
- ['empty string → null', '', null],
- ['invalid expression → null', 'not a cron', null],
- ])('%s', (_label, expression, expected) => {
- expect(humanizeCron(expression)).toBe(expected);
- });
-});
diff --git a/assets/test/utils/humanizeCron.test.ts b/assets/test/utils/humanizeCron.test.ts
new file mode 100644
index 00000000000..8bb415400cb
--- /dev/null
+++ b/assets/test/utils/humanizeCron.test.ts
@@ -0,0 +1,48 @@
+/**
+ * humanizeCron tests
+ *
+ * Covers `humanizeCron` — the cron → friendly text helper used by the cron
+ * show panel, the builder preview and the diagram node. (Schedule authoring is
+ * handled by the CronFieldBuilder dropdown, which does its own parsing, so
+ * there is no natural-language parser to test.)
+ */
+
+import { describe, expect, test } from 'vitest';
+
+import { humanizeCron } from '../../js/utils/humanizeCron';
+
+describe('humanizeCron', () => {
+ test.each<[string, string, string | null]>([
+ ['daily at 9 AM', '0 9 * * *', 'At 09:00 AM UTC'],
+ ['every 15 minutes', '*/15 * * * *', 'Every 15 minutes'],
+ [
+ 'every 2 hours',
+ '0 */2 * * *',
+ 'At 0 minutes past the hour, every 2 hours',
+ ],
+ [
+ 'weekdays at 9 AM',
+ '0 9 * * 1-5',
+ 'At 09:00 AM UTC, Monday through Friday',
+ ],
+ [
+ 'monthly on the 15th',
+ '30 9 15 * *',
+ 'At 09:30 AM UTC, on day 15 of the month',
+ ],
+ [
+ 'weekly on Sunday midnight',
+ '0 0 * * 0',
+ 'At 12:00 AM UTC, only on Sunday',
+ ],
+ [
+ 'hour range',
+ '0 9-17 * * 1-5',
+ 'Every hour, between 09:00 AM and 05:00 PM UTC, Monday through Friday',
+ ],
+ ['empty string → null', '', null],
+ ['invalid expression → null', 'not a cron', null],
+ ])('%s', (_label, expression, expected) => {
+ expect(humanizeCron(expression)).toBe(expected);
+ });
+});
diff --git a/assets/test/workflow-diagram/nodes/Trigger.test.tsx b/assets/test/workflow-diagram/nodes/Trigger.test.tsx
index 19bc2d16be3..675c1bcea13 100644
--- a/assets/test/workflow-diagram/nodes/Trigger.test.tsx
+++ b/assets/test/workflow-diagram/nodes/Trigger.test.tsx
@@ -2,7 +2,8 @@
* Trigger Node Component Tests
*
* Tests that trigger nodes display correct icons for all trigger types
- * (webhook, cron) and handle edge cases gracefully.
+ * (webhook, cron) and handle edge cases gracefully, and that a cron node's
+ * sublabel shows the humanized schedule.
*/
import { render } from '@testing-library/react';
@@ -15,11 +16,12 @@ import type { Lightning } from '../../../js/workflow-diagram/types';
/**
* Helper to render a trigger node within ReactFlow context
*
- * Note: TriggerNode expects data.trigger to contain the trigger info,
- * which is how from-workflow structures it
+ * Mirrors from-workflow: data holds the trigger's own fields (which
+ * TriggerNode reads) plus a data.trigger summary
*/
function renderTriggerNode(trigger: Lightning.TriggerNode) {
const data: any = {
+ ...trigger,
trigger: {
type: trigger.type,
enabled: trigger.enabled,
@@ -127,3 +129,22 @@ describe('Trigger Node - Icon Display', () => {
expect(svgElements.length).toBeGreaterThanOrEqual(1);
});
});
+
+describe('Trigger Node - Cron Sublabel', () => {
+ test.each([
+ ['0 9 * * 1-5', 'At 09:00 AM UTC, Monday through Friday'],
+ ['invalid cron', 'Invalid cron expression'],
+ ['', 'Not configured'],
+ ])('%j shows "%s"', (cron_expression, expected) => {
+ const { getByText } = renderTriggerNode({
+ id: 'trigger-5',
+ name: 'Cron Trigger',
+ type: 'cron',
+ enabled: true,
+ cron_expression,
+ workflow_id: 'test-workflow',
+ });
+
+ expect(getByText(expected)).toBeInTheDocument();
+ });
+});