From c2c9d64e1a9c006f624ce7ac37d89626eb8ff076 Mon Sep 17 00:00:00 2001 From: Suren Hakobyan Date: Wed, 6 May 2026 12:48:06 +0400 Subject: [PATCH 1/4] fix: use UTC getters in formatDate/formatDateTime to prevent timezone drift `formatDateTime` and `formatDate` use `new Date(input)` followed by local-time getters (`getFullYear`, `getMonth`, `getDate`, `getHours`, etc.). On CI runners or servers not in UTC, this produces shifted dates. For example, `formatDate("2023-01-01T00:30:00Z")` on a UTC+2 machine returns "2023-01-01" correctly, but on a UTC-5 machine it returns "2022-12-31" because 00:30 UTC is still Dec 31 in UTC-5. Since these functions serialize values for D1/SQLite storage where dates are inherently UTC, this commit: 1. Early-returns when input is already in canonical format (avoids needless round-trip through Date). 2. Normalizes space-separated datetime strings ("YYYY-MM-DD HH:mm:ss") to ISO format with Z suffix before parsing, ensuring UTC interpretation. 3. Replaces all local-time getters with UTC equivalents. Both copies (src/utils and src/runtime/internal/preview) are updated. Tests are updated to assert deterministic UTC output. --- src/runtime/internal/preview/utils.ts | 33 ++++++++++--- src/utils/content/transformers/utils.ts | 28 ++++++++--- test/unit/formatDate.test.ts | 65 +++++++++++++++---------- 3 files changed, 85 insertions(+), 41 deletions(-) diff --git a/src/runtime/internal/preview/utils.ts b/src/runtime/internal/preview/utils.ts index 11dd0a336..7a10aeb20 100644 --- a/src/runtime/internal/preview/utils.ts +++ b/src/runtime/internal/preview/utils.ts @@ -77,17 +77,26 @@ export function parseSourceBase(source: CollectionSource) { * Importing it from the preview runtime causes a broken path in the * published package. * + * Uses UTC getters to avoid timezone-dependent date shifts on non-UTC + * CI runners or servers. + * * @see https://github.com/nuxt/content/issues/3742 */ export const formatDate = (date: string): string => { - const d = new Date(date) + if (typeof date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(date)) { + return date + } + const normalized = typeof date === 'string' + ? date.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') + : date + const d = new Date(normalized) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid date value: "${date}"`) } - const year = d.getFullYear() - const month = d.getMonth() + 1 - const day = d.getDate() + const year = d.getUTCFullYear() + const month = d.getUTCMonth() + 1 + const day = d.getUTCDate() return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}` } @@ -95,18 +104,26 @@ export const formatDate = (date: string): string => { /** * Format a date string as `YYYY-MM-DD HH:mm:ss` for SQL DATETIME columns. * + * Uses UTC getters to avoid timezone-dependent shifts. + * * @see {@link formatDate} for why this is duplicated here. * @see https://github.com/nuxt/content/issues/3742 */ export const formatDateTime = (datetime: string): string => { - const d = new Date(datetime) + if (typeof datetime === 'string' && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(datetime)) { + return datetime + } + const normalized = typeof datetime === 'string' + ? datetime.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') + : datetime + const d = new Date(normalized) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid datetime value: "${datetime}"`) } - const hours = d.getHours() - const minutes = d.getMinutes() - const seconds = d.getSeconds() + const hours = d.getUTCHours() + const minutes = d.getUTCMinutes() + const seconds = d.getUTCSeconds() return `${formatDate(datetime)} ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` } diff --git a/src/utils/content/transformers/utils.ts b/src/utils/content/transformers/utils.ts index 499b2dcda..a181ca4ab 100644 --- a/src/utils/content/transformers/utils.ts +++ b/src/utils/content/transformers/utils.ts @@ -5,27 +5,39 @@ export const defineTransformer = (transformer: ContentTransformer) => { } export const formatDateTime = (datetime: string): string => { - const d = new Date(datetime) + if (typeof datetime === 'string' && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(datetime)) { + return datetime + } + const normalized = typeof datetime === 'string' + ? datetime.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') + : datetime + const d = new Date(normalized) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid datetime value: "${datetime}"`) } - const hours = d.getHours() - const minutes = d.getMinutes() - const seconds = d.getSeconds() + const hours = d.getUTCHours() + const minutes = d.getUTCMinutes() + const seconds = d.getUTCSeconds() return `${formatDate(datetime)} ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` } export const formatDate = (date: string): string => { - const d = new Date(date) + if (typeof date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(date)) { + return date + } + const normalized = typeof date === 'string' + ? date.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') + : date + const d = new Date(normalized) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid date value: "${date}"`) } - const year = d.getFullYear() - const month = d.getMonth() + 1 - const day = d.getDate() + const year = d.getUTCFullYear() + const month = d.getUTCMonth() + 1 + const day = d.getUTCDate() return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}` } diff --git a/test/unit/formatDate.test.ts b/test/unit/formatDate.test.ts index be1b03cae..e5586e37f 100644 --- a/test/unit/formatDate.test.ts +++ b/test/unit/formatDate.test.ts @@ -2,27 +2,31 @@ import { describe, expect, it } from 'vitest' import { formatDate, formatDateTime } from '../../src/runtime/internal/preview/utils' describe('formatDate', () => { - it('formats a date string as YYYY-MM-DD', () => { - // formatDate uses local time (getFullYear/getMonth/getDate), so we - // construct expected values the same way to stay timezone-agnostic. - const input = '2022-06-15T12:00:00.000Z' - const d = new Date(input) - const expected = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` - expect(formatDate(input)).toBe(expected) + it('formats an ISO date string as YYYY-MM-DD using UTC', () => { + expect(formatDate('2022-06-15T12:00:00.000Z')).toBe('2022-06-15') + }) + + it('returns canonical date strings unchanged', () => { + expect(formatDate('2022-06-15')).toBe('2022-06-15') + expect(formatDate('2024-01-01')).toBe('2024-01-01') }) it('pads single-digit month and day', () => { - const input = '2022-01-05T12:00:00.000Z' - const result = formatDate(input) - // Format is always YYYY-MM-DD with zero-padded segments - expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/) - expect(result).toContain('-05') + expect(formatDate('2022-01-05T00:00:00.000Z')).toBe('2022-01-05') }) - it('handles end-of-year dates', () => { - const input = '2022-12-31T12:00:00.000Z' - const result = formatDate(input) - expect(result).toMatch(/^\d{4}-12-31$/) + it('handles end-of-year dates consistently in UTC', () => { + // 2022-12-31T23:00:00Z is still Dec 31 in UTC even if it's Jan 1 locally + expect(formatDate('2022-12-31T23:00:00.000Z')).toBe('2022-12-31') + }) + + it('handles dates near midnight boundary in UTC', () => { + // This is Jan 1 00:30 UTC — should be 2023-01-01, not 2022-12-31 + expect(formatDate('2023-01-01T00:30:00.000Z')).toBe('2023-01-01') + }) + + it('parses space-separated datetime as UTC', () => { + expect(formatDate('2022-06-15 14:30:00')).toBe('2022-06-15') }) it('throws on invalid date', () => { @@ -31,7 +35,6 @@ describe('formatDate', () => { }) it('produces same output as the build-time copy', async () => { - // Guard against the two copies drifting apart. const buildTime = await import('../../src/utils/content/transformers/utils') const inputs = ['2022-06-15T12:00:00.000Z', '2023-01-01T00:00:00.000Z', '2024-12-31T23:59:59.000Z'] for (const input of inputs) { @@ -41,17 +44,29 @@ describe('formatDate', () => { }) describe('formatDateTime', () => { - it('formats a datetime string as YYYY-MM-DD HH:mm:ss', () => { - const input = '2022-06-15T14:30:45.000Z' - const result = formatDateTime(input) - expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/) - // The date portion must match formatDate - expect(result.split(' ')[0]).toBe(formatDate(input)) + it('formats an ISO datetime string as YYYY-MM-DD HH:mm:ss using UTC', () => { + expect(formatDateTime('2022-06-15T14:30:45.000Z')).toBe('2022-06-15 14:30:45') + }) + + it('returns canonical datetime strings unchanged', () => { + expect(formatDateTime('2022-06-15 14:30:45')).toBe('2022-06-15 14:30:45') }) it('pads single-digit hours, minutes, and seconds', () => { - const result = formatDateTime('2022-01-01T01:02:03.000Z') - expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/) + expect(formatDateTime('2022-01-01T01:02:03.000Z')).toBe('2022-01-01 01:02:03') + }) + + it('uses UTC time components regardless of system timezone', () => { + // Midnight UTC should always produce 00:00:00 + expect(formatDateTime('2022-06-15T00:00:00.000Z')).toBe('2022-06-15 00:00:00') + // 23:59:59 UTC should always produce that time, not shift to next day + expect(formatDateTime('2022-12-31T23:59:59.000Z')).toBe('2022-12-31 23:59:59') + }) + + it('the date portion matches formatDate output', () => { + const input = '2022-06-15T14:30:45.000Z' + const result = formatDateTime(input) + expect(result.split(' ')[0]).toBe(formatDate(input)) }) it('throws on invalid datetime', () => { From 00a1a92c897981956184e1c820d031925e1f29e5 Mon Sep 17 00:00:00 2001 From: Suren Hakobyan Date: Wed, 6 May 2026 13:12:36 +0400 Subject: [PATCH 2/4] refactor: remove redundant typeof guards and eliminate double-parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove `typeof x === 'string'` checks — the parameter types already enforce string input at the TypeScript level - formatDateTime now extracts all date/time components from a single parsed Date object instead of delegating to formatDate(). This avoids parsing and normalizing the input twice and makes the data flow clearer. --- src/runtime/internal/preview/utils.ts | 17 ++++++++--------- src/utils/content/transformers/utils.ts | 17 ++++++++--------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/src/runtime/internal/preview/utils.ts b/src/runtime/internal/preview/utils.ts index 7a10aeb20..a7e241411 100644 --- a/src/runtime/internal/preview/utils.ts +++ b/src/runtime/internal/preview/utils.ts @@ -83,12 +83,10 @@ export function parseSourceBase(source: CollectionSource) { * @see https://github.com/nuxt/content/issues/3742 */ export const formatDate = (date: string): string => { - if (typeof date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(date)) { + if (/^\d{4}-\d{2}-\d{2}$/.test(date)) { return date } - const normalized = typeof date === 'string' - ? date.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') - : date + const normalized = date.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') const d = new Date(normalized) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid date value: "${date}"`) @@ -110,20 +108,21 @@ export const formatDate = (date: string): string => { * @see https://github.com/nuxt/content/issues/3742 */ export const formatDateTime = (datetime: string): string => { - if (typeof datetime === 'string' && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(datetime)) { + if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(datetime)) { return datetime } - const normalized = typeof datetime === 'string' - ? datetime.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') - : datetime + const normalized = datetime.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') const d = new Date(normalized) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid datetime value: "${datetime}"`) } + const year = d.getUTCFullYear() + const month = d.getUTCMonth() + 1 + const day = d.getUTCDate() const hours = d.getUTCHours() const minutes = d.getUTCMinutes() const seconds = d.getUTCSeconds() - return `${formatDate(datetime)} ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` + return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')} ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` } diff --git a/src/utils/content/transformers/utils.ts b/src/utils/content/transformers/utils.ts index a181ca4ab..d521d491e 100644 --- a/src/utils/content/transformers/utils.ts +++ b/src/utils/content/transformers/utils.ts @@ -5,31 +5,30 @@ export const defineTransformer = (transformer: ContentTransformer) => { } export const formatDateTime = (datetime: string): string => { - if (typeof datetime === 'string' && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(datetime)) { + if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(datetime)) { return datetime } - const normalized = typeof datetime === 'string' - ? datetime.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') - : datetime + const normalized = datetime.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') const d = new Date(normalized) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid datetime value: "${datetime}"`) } + const year = d.getUTCFullYear() + const month = d.getUTCMonth() + 1 + const day = d.getUTCDate() const hours = d.getUTCHours() const minutes = d.getUTCMinutes() const seconds = d.getUTCSeconds() - return `${formatDate(datetime)} ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` + return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')} ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` } export const formatDate = (date: string): string => { - if (typeof date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(date)) { + if (/^\d{4}-\d{2}-\d{2}$/.test(date)) { return date } - const normalized = typeof date === 'string' - ? date.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') - : date + const normalized = date.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') const d = new Date(normalized) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid date value: "${date}"`) From 1ce1618d9868b5fbbbf64380c00cff6f849408eb Mon Sep 17 00:00:00 2001 From: Suren Hakobyan Date: Wed, 6 May 2026 13:16:55 +0400 Subject: [PATCH 3/4] fix: accept Date objects in formatDate/formatDateTime for runtime callers The build-time collection utilities pass Date objects (cast with `as string`) to these functions. Rather than the previous `typeof` ternary pattern, explicitly widen the type to `string | Date` and normalize with `instanceof Date` at the entry point. This is cleaner than the previous approach because: - The type signature documents the actual runtime contract - A single coercion path at the top (no branching in the middle) - Date objects get proper UTC handling via `.toISOString()` --- src/runtime/internal/preview/utils.ts | 18 ++++++++++-------- src/utils/content/transformers/utils.ts | 18 ++++++++++-------- test/unit/formatDate.test.ts | 5 +++++ 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/runtime/internal/preview/utils.ts b/src/runtime/internal/preview/utils.ts index a7e241411..cd7f1a5f0 100644 --- a/src/runtime/internal/preview/utils.ts +++ b/src/runtime/internal/preview/utils.ts @@ -82,11 +82,12 @@ export function parseSourceBase(source: CollectionSource) { * * @see https://github.com/nuxt/content/issues/3742 */ -export const formatDate = (date: string): string => { - if (/^\d{4}-\d{2}-\d{2}$/.test(date)) { - return date +export const formatDate = (date: string | Date): string => { + const input = date instanceof Date ? date.toISOString() : String(date) + if (/^\d{4}-\d{2}-\d{2}$/.test(input)) { + return input } - const normalized = date.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') + const normalized = input.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') const d = new Date(normalized) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid date value: "${date}"`) @@ -107,11 +108,12 @@ export const formatDate = (date: string): string => { * @see {@link formatDate} for why this is duplicated here. * @see https://github.com/nuxt/content/issues/3742 */ -export const formatDateTime = (datetime: string): string => { - if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(datetime)) { - return datetime +export const formatDateTime = (datetime: string | Date): string => { + const input = datetime instanceof Date ? datetime.toISOString() : String(datetime) + if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(input)) { + return input } - const normalized = datetime.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') + const normalized = input.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') const d = new Date(normalized) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid datetime value: "${datetime}"`) diff --git a/src/utils/content/transformers/utils.ts b/src/utils/content/transformers/utils.ts index d521d491e..bb2a496b0 100644 --- a/src/utils/content/transformers/utils.ts +++ b/src/utils/content/transformers/utils.ts @@ -4,11 +4,12 @@ export const defineTransformer = (transformer: ContentTransformer) => { return transformer } -export const formatDateTime = (datetime: string): string => { - if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(datetime)) { - return datetime +export const formatDateTime = (datetime: string | Date): string => { + const input = datetime instanceof Date ? datetime.toISOString() : String(datetime) + if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(input)) { + return input } - const normalized = datetime.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') + const normalized = input.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') const d = new Date(normalized) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid datetime value: "${datetime}"`) @@ -24,11 +25,12 @@ export const formatDateTime = (datetime: string): string => { return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')} ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` } -export const formatDate = (date: string): string => { - if (/^\d{4}-\d{2}-\d{2}$/.test(date)) { - return date +export const formatDate = (date: string | Date): string => { + const input = date instanceof Date ? date.toISOString() : String(date) + if (/^\d{4}-\d{2}-\d{2}$/.test(input)) { + return input } - const normalized = date.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') + const normalized = input.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') const d = new Date(normalized) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid date value: "${date}"`) diff --git a/test/unit/formatDate.test.ts b/test/unit/formatDate.test.ts index e5586e37f..6dfd8a4b1 100644 --- a/test/unit/formatDate.test.ts +++ b/test/unit/formatDate.test.ts @@ -29,6 +29,11 @@ describe('formatDate', () => { expect(formatDate('2022-06-15 14:30:00')).toBe('2022-06-15') }) + it('handles Date object input', () => { + const date = new Date('2022-06-15T14:30:00.000Z') + expect(formatDate(date as unknown as string)).toBe('2022-06-15') + }) + it('throws on invalid date', () => { expect(() => formatDate('not-a-date')).toThrow(TypeError) expect(() => formatDate('not-a-date')).toThrow('Invalid date value') From 3bce5acda0d7f6daa1b2efac944635bdb3ba100f Mon Sep 17 00:00:00 2001 From: Suren Hakobyan Date: Wed, 6 May 2026 13:26:50 +0400 Subject: [PATCH 4/4] test: remove unnecessary type cast now that signature accepts Date --- test/unit/formatDate.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/unit/formatDate.test.ts b/test/unit/formatDate.test.ts index 6dfd8a4b1..461b0858e 100644 --- a/test/unit/formatDate.test.ts +++ b/test/unit/formatDate.test.ts @@ -30,8 +30,7 @@ describe('formatDate', () => { }) it('handles Date object input', () => { - const date = new Date('2022-06-15T14:30:00.000Z') - expect(formatDate(date as unknown as string)).toBe('2022-06-15') + expect(formatDate(new Date('2022-06-15T14:30:00.000Z'))).toBe('2022-06-15') }) it('throws on invalid date', () => {