From b11b3bad25f6bbe5efc14aa9ead46dea14b52cd5 Mon Sep 17 00:00:00 2001 From: Amit Gupta Date: Thu, 9 Jul 2026 18:52:41 -0700 Subject: [PATCH] fix(date): %s returns Unix epoch unaffected by display timezone The %s handler read LiquidDate.getTime(), which returns the displayDate deliberately shifted by the display timezone offset for wall-clock getters. With a timezone argument or timezoneOffset option set, %s produced an epoch shifted by (server offset - display offset) instead of the true Unix timestamp. Expose the unshifted time as LiquidDate.dateValue() and use it for %s. Also switch Math.round to Math.floor so fractional seconds truncate toward the epoch like Ruby strftime. Fixes #931 --- docs/source/filters/date.md | 1 + src/util/liquid-date.ts | 9 +++++++++ src/util/strftime.ts | 2 +- test/integration/filters/date.spec.ts | 12 ++++++++++++ 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/source/filters/date.md b/docs/source/filters/date.md index 222b88bcec..486655c4b3 100644 --- a/docs/source/filters/date.md +++ b/docs/source/filters/date.md @@ -34,6 +34,7 @@ The `date` filter is used to convert a timestamp into the specified format. * minutes: `-360` means `'+06:00'` and `360` means `'-06:00'` * timeZone ID: `Asia/Colombo` or `America/New_York` * See [here](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for TZ database values +* `%s` (seconds since the Unix epoch) identifies an instant rather than a wall-clock time, so it's not affected by the display timezone. ### Examples ```liquid diff --git a/src/util/liquid-date.ts b/src/util/liquid-date.ts index d3d141f6d5..e92ffef440 100644 --- a/src/util/liquid-date.ts +++ b/src/util/liquid-date.ts @@ -53,6 +53,15 @@ export class LiquidDate { getTime () { return this.displayDate.getTime() } + /** + * The underlying UTC timestamp in milliseconds, unaffected by the display + * timezone. Use this (not `getTime()`) for timezone-invariant values like + * `%s`: `getTime()` reads `displayDate`, which is deliberately shifted by + * the display timezone offset so wall-clock getters can delegate to Date. + */ + dateValue () { + return this.date.getTime() + } getMilliseconds () { return this.displayDate.getMilliseconds() } diff --git a/src/util/strftime.ts b/src/util/strftime.ts index 1d303c15f5..cfbcd58116 100644 --- a/src/util/strftime.ts +++ b/src/util/strftime.ts @@ -105,7 +105,7 @@ const formatCodes: Record = { p: (d: LiquidDate) => (d.getHours() < 12 ? 'AM' : 'PM'), P: (d: LiquidDate) => (d.getHours() < 12 ? 'am' : 'pm'), q: (d: LiquidDate) => ordinal(d), - s: (d: LiquidDate) => Math.round(d.getTime() / 1000), + s: (d: LiquidDate) => Math.floor(d.dateValue() / 1000), S: (d: LiquidDate) => d.getSeconds(), u: (d: LiquidDate) => d.getDay() || 7, U: (d: LiquidDate) => getWeekOfYear(d, 0), diff --git a/test/integration/filters/date.spec.ts b/test/integration/filters/date.spec.ts index 9928769061..707886b463 100644 --- a/test/integration/filters/date.spec.ts +++ b/test/integration/filters/date.spec.ts @@ -140,6 +140,18 @@ describe('filters/date', function () { it('should support timezone name argument when DST is active', function () { return test('{{ "2021-06-01T23:00:00Z" | date: "%Y-%m-%dT%H:%M:%S", "America/New_York" }}', '2021-06-01T19:00:00') }) + it('should not shift %s by the timezone name argument', function () { + return test('{{ "2026-06-30T21:00:00Z" | date: "%s", "America/Toronto" }}', '1782853200') + }) + it('should not shift %s by the timezone offset argument', function () { + return test('{{ "2026-06-30T21:00:00Z" | date: "%s", 360 }}', '1782853200') + }) + it('should not shift %s by the timezoneOffset option', function () { + return test('{{ "2026-06-30T21:00:00Z" | date: "%s" }}', '1782853200', undefined, opts) + }) + it('should truncate %s toward the epoch like Ruby strftime', function () { + return test('{{ "2026-06-30T17:00:00.500Z" | date: "%s" }}', '1782838800') + }) it('should offset date literal with timezone 00:00 specified', function () { return test('{{ "1990-12-31T23:00:00+00:00" | date: "%Y-%m-%dT%H:%M:%S"}}', '1990-12-31T17:00:00', undefined, opts) })