Skip to content

Commit dc6f535

Browse files
committed
fix: treat offset-less datetimes as UTC
1 parent 983c568 commit dc6f535

3 files changed

Lines changed: 155 additions & 44 deletions

File tree

src/runtime/internal/preview/utils.ts

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -70,25 +70,19 @@ export function parseSourceBase(source: CollectionSource) {
7070
}
7171

7272
/**
73-
* Format a date string as `YYYY-MM-DD` for SQL DATE columns.
73+
* Format a date value as `YYYY-MM-DD` for SQL DATE columns.
7474
*
7575
* Duplicated from `src/utils/content/transformers/utils.ts` because that
7676
* file lives outside the `runtime/` subtree and is not emitted to dist.
7777
* Importing it from the preview runtime causes a broken path in the
7878
* published package.
7979
*
80-
* Uses UTC getters to avoid timezone-dependent date shifts on non-UTC
81-
* CI runners or servers.
80+
* Always uses UTC. Offset-less datetimes are treated as UTC.
8281
*
8382
* @see https://github.com/nuxt/content/issues/3742
8483
*/
8584
export const formatDate = (date: string | Date): string => {
86-
const input = date instanceof Date ? date.toISOString() : String(date)
87-
if (/^\d{4}-\d{2}-\d{2}$/.test(input)) {
88-
return input
89-
}
90-
const normalized = input.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z')
91-
const d = new Date(normalized)
85+
const d = toUtcDate(date)
9286
if (Number.isNaN(d.getTime())) {
9387
throw new TypeError(`Invalid date value: "${date}"`)
9488
}
@@ -101,20 +95,15 @@ export const formatDate = (date: string | Date): string => {
10195
}
10296

10397
/**
104-
* Format a date string as `YYYY-MM-DD HH:mm:ss` for SQL DATETIME columns.
98+
* Format a datetime value as `YYYY-MM-DD HH:mm:ss` for SQL DATETIME columns.
10599
*
106-
* Uses UTC getters to avoid timezone-dependent shifts.
100+
* Always uses UTC. Offset-less datetimes are treated as UTC.
107101
*
108102
* @see {@link formatDate} for why this is duplicated here.
109103
* @see https://github.com/nuxt/content/issues/3742
110104
*/
111105
export const formatDateTime = (datetime: string | Date): string => {
112-
const input = datetime instanceof Date ? datetime.toISOString() : String(datetime)
113-
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(input)) {
114-
return input
115-
}
116-
const normalized = input.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z')
117-
const d = new Date(normalized)
106+
const d = toUtcDate(datetime)
118107
if (Number.isNaN(d.getTime())) {
119108
throw new TypeError(`Invalid datetime value: "${datetime}"`)
120109
}
@@ -128,3 +117,41 @@ export const formatDateTime = (datetime: string | Date): string => {
128117

129118
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')}`
130119
}
120+
121+
/**
122+
* Parse a date/datetime value as UTC.
123+
*
124+
* - Date objects are used as-is
125+
* - Space-separated datetimes (`YYYY-MM-DD HH:mm:ss[.sss]`) become ISO + Z
126+
* - Offset-less ISO datetimes (`YYYY-MM-DDTHH:mm:ss[.sss]`) get a Z suffix
127+
* - Date-only (`YYYY-MM-DD`) and values that already include Z/offset pass through
128+
*/
129+
function toUtcDate(value: string | Date): Date {
130+
if (value instanceof Date) {
131+
return value
132+
}
133+
134+
const input = String(value).trim()
135+
136+
// Already has an explicit offset or Z — Date parses correctly as absolute time
137+
if (/(?:z|[+-]\d{2}:?\d{2})$/i.test(input)) {
138+
return new Date(input)
139+
}
140+
141+
// Space-separated SQL-style datetime → ISO + Z
142+
const spaceSeparated = input.replace(
143+
/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/,
144+
'$1T$2$3Z',
145+
)
146+
if (spaceSeparated !== input) {
147+
return new Date(spaceSeparated)
148+
}
149+
150+
// Offset-less ISO datetime (`2023-01-01T00:00:00`) → treat as UTC
151+
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(input)) {
152+
return new Date(`${input}Z`)
153+
}
154+
155+
// Date-only and everything else — Date-only is already UTC midnight per ES
156+
return new Date(input)
157+
}

src/utils/content/transformers/utils.ts

Lines changed: 57 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,32 @@ export const defineTransformer = (transformer: ContentTransformer) => {
44
return transformer
55
}
66

7-
export const formatDateTime = (datetime: string | Date): string => {
8-
const input = datetime instanceof Date ? datetime.toISOString() : String(datetime)
9-
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(input)) {
10-
return input
7+
/**
8+
* Format a date value as `YYYY-MM-DD` for SQL DATE columns.
9+
*
10+
* Always uses UTC. Offset-less datetimes (e.g. `2023-01-01T00:00:00`,
11+
* `2023-01-01 00:00:00`) are treated as UTC rather than local time.
12+
*/
13+
export const formatDate = (date: string | Date): string => {
14+
const d = toUtcDate(date)
15+
if (Number.isNaN(d.getTime())) {
16+
throw new TypeError(`Invalid date value: "${date}"`)
1117
}
12-
const normalized = input.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z')
13-
const d = new Date(normalized)
18+
19+
const year = d.getUTCFullYear()
20+
const month = d.getUTCMonth() + 1
21+
const day = d.getUTCDate()
22+
23+
return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`
24+
}
25+
26+
/**
27+
* Format a datetime value as `YYYY-MM-DD HH:mm:ss` for SQL DATETIME columns.
28+
*
29+
* Always uses UTC. Offset-less datetimes are treated as UTC.
30+
*/
31+
export const formatDateTime = (datetime: string | Date): string => {
32+
const d = toUtcDate(datetime)
1433
if (Number.isNaN(d.getTime())) {
1534
throw new TypeError(`Invalid datetime value: "${datetime}"`)
1635
}
@@ -25,20 +44,40 @@ export const formatDateTime = (datetime: string | Date): string => {
2544
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')}`
2645
}
2746

28-
export const formatDate = (date: string | Date): string => {
29-
const input = date instanceof Date ? date.toISOString() : String(date)
30-
if (/^\d{4}-\d{2}-\d{2}$/.test(input)) {
31-
return input
47+
/**
48+
* Parse a date/datetime value as UTC.
49+
*
50+
* - Date objects are used as-is
51+
* - Space-separated datetimes (`YYYY-MM-DD HH:mm:ss[.sss]`) become ISO + Z
52+
* - Offset-less ISO datetimes (`YYYY-MM-DDTHH:mm:ss[.sss]`) get a Z suffix
53+
* - Date-only (`YYYY-MM-DD`) and values that already include Z/offset pass through
54+
*/
55+
function toUtcDate(value: string | Date): Date {
56+
if (value instanceof Date) {
57+
return value
3258
}
33-
const normalized = input.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z')
34-
const d = new Date(normalized)
35-
if (Number.isNaN(d.getTime())) {
36-
throw new TypeError(`Invalid date value: "${date}"`)
59+
60+
const input = String(value).trim()
61+
62+
// Already has an explicit offset or Z — Date parses correctly as absolute time
63+
if (/(?:z|[+-]\d{2}:?\d{2})$/i.test(input)) {
64+
return new Date(input)
3765
}
3866

39-
const year = d.getUTCFullYear()
40-
const month = d.getUTCMonth() + 1
41-
const day = d.getUTCDate()
67+
// Space-separated SQL-style datetime → ISO + Z
68+
const spaceSeparated = input.replace(
69+
/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/,
70+
'$1T$2$3Z',
71+
)
72+
if (spaceSeparated !== input) {
73+
return new Date(spaceSeparated)
74+
}
4275

43-
return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`
76+
// Offset-less ISO datetime (`2023-01-01T00:00:00`) → treat as UTC
77+
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(input)) {
78+
return new Date(`${input}Z`)
79+
}
80+
81+
// Date-only and everything else — Date-only is already UTC midnight per ES
82+
return new Date(input)
4483
}

test/unit/formatDate.test.ts

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,21 +16,38 @@ describe('formatDate', () => {
1616
})
1717

1818
it('handles end-of-year dates consistently in UTC', () => {
19-
// 2022-12-31T23:00:00Z is still Dec 31 in UTC even if it's Jan 1 locally
19+
// Still Dec 31 in UTC even when local TZ has crossed into Jan 1
2020
expect(formatDate('2022-12-31T23:00:00.000Z')).toBe('2022-12-31')
2121
})
2222

2323
it('handles dates near midnight boundary in UTC', () => {
24-
// This is Jan 1 00:30 UTC — should be 2023-01-01, not 2022-12-31
2524
expect(formatDate('2023-01-01T00:30:00.000Z')).toBe('2023-01-01')
2625
})
2726

27+
it('treats date-only strings as UTC calendar dates', () => {
28+
expect(formatDate('2023-01-01')).toBe('2023-01-01')
29+
expect(formatDate('2024-12-31')).toBe('2024-12-31')
30+
})
31+
32+
it('treats offset-less ISO datetimes as UTC', () => {
33+
// ES would parse this as local time; we force UTC
34+
expect(formatDate('2023-01-01T00:00:00')).toBe('2023-01-01')
35+
expect(formatDate('2022-12-31T23:30:00')).toBe('2022-12-31')
36+
})
37+
2838
it('parses space-separated datetime as UTC', () => {
2939
expect(formatDate('2022-06-15 14:30:00')).toBe('2022-06-15')
3040
})
3141

32-
it('handles Date object input', () => {
42+
it('respects explicit non-UTC offsets', () => {
43+
// 2023-01-01 00:30 in UTC+5:30 → 2022-12-31 19:00 UTC
44+
expect(formatDate('2023-01-01T00:30:00+05:30')).toBe('2022-12-31')
45+
})
46+
47+
it('handles Date object input via UTC components', () => {
3348
expect(formatDate(new Date('2022-06-15T14:30:00.000Z'))).toBe('2022-06-15')
49+
// Near midnight UTC boundary
50+
expect(formatDate(new Date('2022-12-31T23:00:00.000Z'))).toBe('2022-12-31')
3451
})
3552

3653
it('throws on invalid date', () => {
@@ -40,7 +57,15 @@ describe('formatDate', () => {
4057

4158
it('produces same output as the build-time copy', async () => {
4259
const buildTime = await import('../../src/utils/content/transformers/utils')
43-
const inputs = ['2022-06-15T12:00:00.000Z', '2023-01-01T00:00:00.000Z', '2024-12-31T23:59:59.000Z']
60+
const inputs = [
61+
'2022-06-15T12:00:00.000Z',
62+
'2023-01-01T00:00:00.000Z',
63+
'2024-12-31T23:59:59.000Z',
64+
'2023-01-01',
65+
'2023-01-01T00:00:00',
66+
'2022-06-15 14:30:00',
67+
'2023-01-01T00:30:00+05:30',
68+
]
4469
for (const input of inputs) {
4570
expect(formatDate(input)).toBe(buildTime.formatDate(input))
4671
}
@@ -61,16 +86,30 @@ describe('formatDateTime', () => {
6186
})
6287

6388
it('uses UTC time components regardless of system timezone', () => {
64-
// Midnight UTC should always produce 00:00:00
6589
expect(formatDateTime('2022-06-15T00:00:00.000Z')).toBe('2022-06-15 00:00:00')
66-
// 23:59:59 UTC should always produce that time, not shift to next day
6790
expect(formatDateTime('2022-12-31T23:59:59.000Z')).toBe('2022-12-31 23:59:59')
6891
})
6992

93+
it('treats offset-less ISO datetimes as UTC', () => {
94+
expect(formatDateTime('2022-06-15T14:30:45')).toBe('2022-06-15 14:30:45')
95+
expect(formatDateTime('2022-12-31T23:00:00')).toBe('2022-12-31 23:00:00')
96+
})
97+
98+
it('parses space-separated datetime as UTC', () => {
99+
expect(formatDateTime('2022-06-15 14:30:45')).toBe('2022-06-15 14:30:45')
100+
})
101+
102+
it('respects explicit non-UTC offsets', () => {
103+
expect(formatDateTime('2022-06-15T14:30:45+02:00')).toBe('2022-06-15 12:30:45')
104+
})
105+
106+
it('handles Date object input', () => {
107+
expect(formatDateTime(new Date('2022-06-15T14:30:45.000Z'))).toBe('2022-06-15 14:30:45')
108+
})
109+
70110
it('the date portion matches formatDate output', () => {
71111
const input = '2022-06-15T14:30:45.000Z'
72-
const result = formatDateTime(input)
73-
expect(result.split(' ')[0]).toBe(formatDate(input))
112+
expect(formatDateTime(input).split(' ')[0]).toBe(formatDate(input))
74113
})
75114

76115
it('throws on invalid datetime', () => {
@@ -80,7 +119,13 @@ describe('formatDateTime', () => {
80119

81120
it('produces same output as the build-time copy', async () => {
82121
const buildTime = await import('../../src/utils/content/transformers/utils')
83-
const inputs = ['2022-06-15T14:30:45.000Z', '2023-01-01T00:00:00.000Z']
122+
const inputs = [
123+
'2022-06-15T14:30:45.000Z',
124+
'2023-01-01T00:00:00.000Z',
125+
'2022-06-15T14:30:45',
126+
'2022-06-15 14:30:45',
127+
'2022-06-15T14:30:45+02:00',
128+
]
84129
for (const input of inputs) {
85130
expect(formatDateTime(input)).toBe(buildTime.formatDateTime(input))
86131
}

0 commit comments

Comments
 (0)