From 7d26d93874df8648f6adae77963d0c1dd0668ea7 Mon Sep 17 00:00:00 2001 From: Cesar Parra Date: Tue, 1 Sep 2026 14:51:59 -0400 Subject: [PATCH 1/4] Fix issue where datetimeformat is off by one due to GMT dates. --- docs/src/app/docs/functions/page.md | 4 ++-- .../editor/lwc/functions/functions-src.js | 2 +- .../std-lib/DateAndTimeFunctions.cls | 23 ++++++++++++++----- .../DateAndTimeFunctionsTest.cls | 6 +++++ 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/docs/src/app/docs/functions/page.md b/docs/src/app/docs/functions/page.md index aa420e93..f3cf9b9c 100644 --- a/docs/src/app/docs/functions/page.md +++ b/docs/src/app/docs/functions/page.md @@ -454,9 +454,9 @@ DATETIME(2020, 1, 1, 12, 0, 0) // 2020-01-01 12:00:00 ### DATETIMEFORMAT -Formats a DateTime into a string using the provided format. +Formats a Date or DateTime into a string using the provided format. -Accepts 2 arguments: the DateTime to format and the format string. +Accepts 2 arguments: the Date or DateTime to format and the format string. ``` DATETIMEFORMAT(DATETIMEVALUE("2020-01-01 12:00:00"), "yyyy-MM-dd") // 2020-01-01 diff --git a/expression-src/main/editor/lwc/functions/functions-src.js b/expression-src/main/editor/lwc/functions/functions-src.js index 440fa3ba..61120b70 100644 --- a/expression-src/main/editor/lwc/functions/functions-src.js +++ b/expression-src/main/editor/lwc/functions/functions-src.js @@ -411,7 +411,7 @@ export const data = [ { "name": "DATETIMEFORMAT", "autoCompleteValue": "DATETIMEFORMAT(", - "description": "Formats a DateTime into a string using the provided format.

Accepts 2 arguments: the DateTime to format and the format string.", + "description": "Formats a Date or DateTime into a string using the provided format.

Accepts 2 arguments: the Date or DateTime to format and the format string.", "examples": [ "DATETIMEFORMAT(DATETIMEVALUE(\"2020-01-01 12:00:00\"), \"yyyy-MM-dd\") // 2020-01-01" ], diff --git a/expression-src/main/src/interpreter/std-lib/DateAndTimeFunctions.cls b/expression-src/main/src/interpreter/std-lib/DateAndTimeFunctions.cls index 7014488b..87162f48 100644 --- a/expression-src/main/src/interpreter/std-lib/DateAndTimeFunctions.cls +++ b/expression-src/main/src/interpreter/std-lib/DateAndTimeFunctions.cls @@ -821,20 +821,22 @@ public with sharing class DateAndTimeFunctions { } /** - * @description Formats a DateTime into a string using the provided format. + * @description Formats a Date or DateTime into a string using the provided format. * - * Accepts 2 arguments: the DateTime to format and the format string. + * Accepts 2 arguments: the Date or DateTime to format and the format string. * @function DATETIMEFORMAT * @example * DATETIMEFORMAT(DATETIMEVALUE("2020-01-01 12:00:00"), "yyyy-MM-dd") // 2020-01-01 */ private class DateTimeFormatFn extends StandardFunction { public override Object call(List arguments) { - // Expect a DateTime + // Expect a Date or a DateTime. Note that in Apex a Date passes an `instanceof Datetime` + // check, but a Datetime does not pass `instanceof Date`, so the Date check must come first. Object dateTimeValue = evaluate(arguments.get(0)); - if (!(dateTimeValue instanceof Datetime)) { + Boolean isDate = dateTimeValue instanceof Date; + if (!isDate && !(dateTimeValue instanceof Datetime)) { throw new FunctionExecutionException( - 'Error executing "DATETIMEFORMAT" function: the argument must evaluate to a datetime value.' + 'Error executing "DATETIMEFORMAT" function: the first argument must evaluate to a date or datetime value.' ); } @@ -842,10 +844,19 @@ public with sharing class DateAndTimeFunctions { Object formatValue = evaluate(arguments.get(1)); if (!(formatValue instanceof String)) { throw new FunctionExecutionException( - 'Error executing "DATETIMEFORMAT" function: the argument must evaluate to a string value.' + 'Error executing "DATETIMEFORMAT" function: the second argument must evaluate to a string value.' ); } + if (isDate) { + // A Date has no time or timezone of its own. Widening it to a Datetime and formatting + // with Datetime.format() would treat it as midnight GMT and render it in the running + // user's timezone, shifting the calendar day. Anchor it in GMT and format in GMT so + // the date is rendered exactly as-is. + Datetime midnightGmt = Datetime.newInstanceGmt((Date)dateTimeValue, Time.newInstance(0, 0, 0, 0)); + return midnightGmt.formatGmt((String)formatValue); + } + return ((Datetime)dateTimeValue).format((String)formatValue); } diff --git a/expression-src/spec/language/std-functions/DateAndTimeFunctionsTest.cls b/expression-src/spec/language/std-functions/DateAndTimeFunctionsTest.cls index d91185f2..b6d94d97 100644 --- a/expression-src/spec/language/std-functions/DateAndTimeFunctionsTest.cls +++ b/expression-src/spec/language/std-functions/DateAndTimeFunctionsTest.cls @@ -225,6 +225,12 @@ private class DateAndTimeFunctionsTest { Assert.areEqual('2015-01-01 01:00:00', result); } + @IsTest + private static void dateTimeFormatFormatsADateWithoutShiftingTheDay() { + Assert.areEqual('September 9, 2026', Evaluator.run('DATETIMEFORMAT(DATE(2026, 9, 9), "MMMM d, yyyy")')); + Assert.areEqual('2026-09-09', Evaluator.run('DATETIMEFORMAT(DATE(2026, 9, 9), "yyyy-MM-dd")')); + } + @IsTest private static void dateToDateTimeConvertsADateToADateTime() { String formula = 'DATETODATETIME(DATEVALUE("2015-01-01"))'; From 22d39be9f3a60456fca19d01779efca4aafbe4cd Mon Sep 17 00:00:00 2001 From: Cesar Parra Date: Tue, 1 Sep 2026 14:57:00 -0400 Subject: [PATCH 2/4] Cleanup --- .../std-lib/DateAndTimeFunctions.cls | 32 +++++++------------ .../DateAndTimeFunctionsTest.cls | 1 - 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/expression-src/main/src/interpreter/std-lib/DateAndTimeFunctions.cls b/expression-src/main/src/interpreter/std-lib/DateAndTimeFunctions.cls index 87162f48..82b17f41 100644 --- a/expression-src/main/src/interpreter/std-lib/DateAndTimeFunctions.cls +++ b/expression-src/main/src/interpreter/std-lib/DateAndTimeFunctions.cls @@ -45,6 +45,12 @@ public with sharing class DateAndTimeFunctions { MILLISECOND, MINUTE, SECOND, HOUR_FN } + // A bare Date carries no time or timezone, so anchor it at GMT midnight. Callers must read it + // back in GMT (formatGmt/getTime) or the calendar day shifts for users west of GMT. + private static Datetime gmtMidnight(Date value) { + return Datetime.newInstanceGmt(value, Time.newInstance(0, 0, 0, 0)); + } + /** * @description Returns a date that is a specified number of days before or after a given date. * @@ -591,7 +597,7 @@ public with sharing class DateAndTimeFunctions { ); } - return Integer.valueOf(Datetime.newInstanceGmt((Date)dateValue, Time.newInstance(0, 0, 0, 0)).format('Y')); + return Integer.valueOf(gmtMidnight((Date)dateValue).format('Y')); } public override Arity getArity() { @@ -673,7 +679,7 @@ public with sharing class DateAndTimeFunctions { if (dateOrDateTimeOrTime instanceof Date) { // Convert to datetime first - Datetime gmtDatetime = Datetime.newInstanceGmt((Date)dateOrDateTimeOrTime, Time.newInstance(0, 0, 0, 0)); + Datetime gmtDatetime = gmtMidnight((Date)dateOrDateTimeOrTime); return gmtDatetime.getTime() / 1000; } else if (dateOrDateTimeOrTime instanceof Datetime) { Datetime gmtDateTime = Datetime.newInstanceGmt(((Datetime)dateOrDateTimeOrTime).date(), ((Datetime)dateOrDateTimeOrTime).time()); @@ -707,16 +713,7 @@ public with sharing class DateAndTimeFunctions { ); } - Date dateObj = (Date)dateValue; - - return Integer.valueOf(Datetime.newInstanceGmt( - dateObj.year(), - dateObj.month(), - dateObj.day(), - 0, - 0, - 0 - ).format('u')); + return Integer.valueOf(gmtMidnight((Date)dateValue).format('u')); } public override Arity getArity() { @@ -830,8 +827,7 @@ public with sharing class DateAndTimeFunctions { */ private class DateTimeFormatFn extends StandardFunction { public override Object call(List arguments) { - // Expect a Date or a DateTime. Note that in Apex a Date passes an `instanceof Datetime` - // check, but a Datetime does not pass `instanceof Date`, so the Date check must come first. + // A Date also passes `instanceof Datetime` in Apex, so check for Date first. Object dateTimeValue = evaluate(arguments.get(0)); Boolean isDate = dateTimeValue instanceof Date; if (!isDate && !(dateTimeValue instanceof Datetime)) { @@ -849,12 +845,8 @@ public with sharing class DateAndTimeFunctions { } if (isDate) { - // A Date has no time or timezone of its own. Widening it to a Datetime and formatting - // with Datetime.format() would treat it as midnight GMT and render it in the running - // user's timezone, shifting the calendar day. Anchor it in GMT and format in GMT so - // the date is rendered exactly as-is. - Datetime midnightGmt = Datetime.newInstanceGmt((Date)dateTimeValue, Time.newInstance(0, 0, 0, 0)); - return midnightGmt.formatGmt((String)formatValue); + // Format in GMT to match the anchor, so the calendar day is rendered as-is. + return gmtMidnight((Date)dateTimeValue).formatGmt((String)formatValue); } return ((Datetime)dateTimeValue).format((String)formatValue); diff --git a/expression-src/spec/language/std-functions/DateAndTimeFunctionsTest.cls b/expression-src/spec/language/std-functions/DateAndTimeFunctionsTest.cls index b6d94d97..4ee8029a 100644 --- a/expression-src/spec/language/std-functions/DateAndTimeFunctionsTest.cls +++ b/expression-src/spec/language/std-functions/DateAndTimeFunctionsTest.cls @@ -227,7 +227,6 @@ private class DateAndTimeFunctionsTest { @IsTest private static void dateTimeFormatFormatsADateWithoutShiftingTheDay() { - Assert.areEqual('September 9, 2026', Evaluator.run('DATETIMEFORMAT(DATE(2026, 9, 9), "MMMM d, yyyy")')); Assert.areEqual('2026-09-09', Evaluator.run('DATETIMEFORMAT(DATE(2026, 9, 9), "yyyy-MM-dd")')); } From 230632c153f4b905c72438c58131c0f4a3e71b8c Mon Sep 17 00:00:00 2001 From: Cesar Parra Date: Tue, 1 Sep 2026 19:37:15 -0400 Subject: [PATCH 3/4] Fixing build --- .github/workflows/build.yaml | 4 ++-- .github/workflows/pr_build.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index d738d59e..f66a543f 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -22,7 +22,7 @@ jobs: - uses: actions/setup-node@v2 with: - node-version: "20" + node-version: lts/* - name: Install NPM run: | @@ -51,7 +51,7 @@ jobs: - uses: actions/setup-node@v2 with: - node-version: "20" + node-version: lts/* - name: Install NPM run: | diff --git a/.github/workflows/pr_build.yaml b/.github/workflows/pr_build.yaml index 0d640544..b16da1f1 100644 --- a/.github/workflows/pr_build.yaml +++ b/.github/workflows/pr_build.yaml @@ -17,7 +17,7 @@ jobs: - uses: actions/setup-node@v2 with: - node-version: "20" + node-version: "22.14.0" - name: Install dependencies run: npm install From 06aa4e66de7a84fed125a2ba82721e041f9c21d5 Mon Sep 17 00:00:00 2001 From: Cesar Parra Date: Wed, 2 Sep 2026 06:29:08 -0400 Subject: [PATCH 4/4] Fixing build target --- .github/workflows/pr_build.yaml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr_build.yaml b/.github/workflows/pr_build.yaml index b16da1f1..b64b0453 100644 --- a/.github/workflows/pr_build.yaml +++ b/.github/workflows/pr_build.yaml @@ -1,9 +1,13 @@ name: PR Build +# `pull_request`, not `pull_request_target`. The latter reads its workflow definition and its +# checkout from the base branch, so changes to this file only take effect after merging to main, +# and the job tests main rather than the pull request's code. Forks get no secrets and cannot run +# this, which is correct. on: - pull_request_target: + pull_request: branches: [ main ] - types: [ opened, reopened, synchronize, closed ] + types: [ opened, reopened, synchronize ] concurrency: group: ${{ github.ref }} @@ -52,4 +56,5 @@ jobs: fi - name: Clean up scratch org + if: always() run: sf org scratch delete --target-org=scratch-org --no-prompt