Skip to content

Commit 79b3b61

Browse files
duncanmccleanclaude
andcommitted
[6.x] Fix date-only formats shifting days due to timezone conversion
Date fields with formats that don't contain time (e.g. Y-m-d) were being unnecessarily converted through UTC, causing days to shift depending on the user's browser timezone. A date like 2024-12-25 could appear as December 24th for users in negative UTC offsets. Date-only formats now bypass timezone conversion entirely, flowing as plain Y-m-d strings between backend and frontend using CalendarDate objects that have no timezone information. Fixes #14413 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6abb6d6 commit 79b3b61

6 files changed

Lines changed: 241 additions & 7 deletions

File tree

resources/js/components/fieldtypes/DateFieldtype.vue

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
import Fieldtype from './Fieldtype.vue';
2424
import DateFormatter from '@/components/DateFormatter.js';
2525
import { DatePicker, DateRangePicker, Button } from '@/components/ui';
26-
import { getLocalTimeZone, parseAbsoluteToLocal, toTimeZone, toZoned } from '@internationalized/date';
26+
import { CalendarDate, getLocalTimeZone, parseAbsoluteToLocal, toTimeZone, toZoned } from '@internationalized/date';
2727
2828
export default {
2929
components: {
@@ -67,11 +67,26 @@ export default {
6767
return this.config.inline;
6868
},
6969
70+
formatHasTime() {
71+
return this.meta?.formatHasTime ?? true;
72+
},
73+
7074
datePickerValue() {
7175
if (!this.value || this.value === 'now') {
7276
return null;
7377
}
7478
79+
if (!this.formatHasTime) {
80+
if (this.isRange) {
81+
return {
82+
start: this.parseDateOnly(this.value.start),
83+
end: this.parseDateOnly(this.value.end),
84+
};
85+
}
86+
87+
return this.parseDateOnly(this.value);
88+
}
89+
7590
if (this.isRange) {
7691
return {
7792
start: parseAbsoluteToLocal(this.value.start),
@@ -128,6 +143,17 @@ export default {
128143
return this.update(null);
129144
}
130145
146+
if (!this.formatHasTime) {
147+
if (this.isRange) {
148+
return this.update({
149+
start: this.formatDateOnly(value.start),
150+
end: this.formatDateOnly(value.end),
151+
});
152+
}
153+
154+
return this.update(this.formatDateOnly(value));
155+
}
156+
131157
// Sometimes, we'll get a CalendarDateTime object, which doesn't include timezone
132158
// information. In that case, we need to convert it to a ZonedDateTime object.
133159
if (!this.isRange && !value.offset && !value.timeZone) {
@@ -157,6 +183,11 @@ export default {
157183
addDate() {
158184
let now = new Date();
159185
186+
if (!this.formatHasTime) {
187+
const str = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
188+
return this.update(this.isRange ? { start: str, end: str } : str);
189+
}
190+
160191
now.setMilliseconds(0);
161192
162193
if (!this.config.time_enabled) {
@@ -167,6 +198,15 @@ export default {
167198
168199
this.update(this.isRange ? { start: str, end: str } : str);
169200
},
201+
202+
parseDateOnly(value) {
203+
const [year, month, day] = value.split('-').map(Number);
204+
return new CalendarDate(year, month, day);
205+
},
206+
207+
formatDateOnly(value) {
208+
return `${value.year}-${String(value.month).padStart(2, '0')}-${String(value.day).padStart(2, '0')}`;
209+
},
170210
},
171211
};
172212
</script>

resources/js/components/ui/DatePicker/DatePicker.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ const getInputLabel = (part) => {
199199
</template>
200200
</div>
201201
<Text
202+
v-if="timeZoneLabel"
202203
class="text-gray-600 dark:text-gray-400 me-1"
203204
size="xs"
204205
v-tooltip="timeZoneName"

resources/js/tests/components/fieldtypes/DateFieldtype.test.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,58 @@ test('datePickerValue returns null when value is "now"', () => {
8282

8383
expect(dateField.vm.datePickerValue).toBe(null);
8484
});
85+
86+
test.each([
87+
['UTC'],
88+
['America/New_York'],
89+
['Australia/Sydney'],
90+
])('date-only format is not affected by timezone (%s)', async (tz) => {
91+
process.env.TZ = tz;
92+
93+
const dateField = makeDateField({
94+
value: '2025-12-25',
95+
meta: { formatHasTime: false },
96+
});
97+
98+
const value = dateField.vm.datePickerValue;
99+
expect(value.year).toBe(2025);
100+
expect(value.month).toBe(12);
101+
expect(value.day).toBe(25);
102+
expect(value.timeZone).toBeUndefined();
103+
});
104+
105+
test('date-only format formats date correctly', async () => {
106+
process.env.TZ = 'America/New_York';
107+
108+
const dateField = makeDateField({
109+
value: '2025-12-25',
110+
meta: { formatHasTime: false },
111+
});
112+
113+
const { CalendarDate } = await import('@internationalized/date');
114+
const formatted = dateField.vm.formatDateOnly(new CalendarDate(2025, 6, 5));
115+
116+
expect(formatted).toBe('2025-06-05');
117+
});
118+
119+
test('date-only range format is not affected by timezone', async () => {
120+
process.env.TZ = 'America/New_York';
121+
122+
const dateField = makeDateField({
123+
value: { start: '2025-12-25', end: '2025-12-31' },
124+
meta: { formatHasTime: false },
125+
config: {
126+
mode: 'range',
127+
earliest_date: { date: null, time: null },
128+
latest_date: { date: null, time: null },
129+
},
130+
});
131+
132+
const value = dateField.vm.datePickerValue;
133+
expect(value.start.year).toBe(2025);
134+
expect(value.start.month).toBe(12);
135+
expect(value.start.day).toBe(25);
136+
expect(value.end.year).toBe(2025);
137+
expect(value.end.month).toBe(12);
138+
expect(value.end.day).toBe(31);
139+
});

src/Fieldtypes/Date.php

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,10 @@ private function preProcessSingle($value)
150150
$value = $value['start'];
151151
}
152152

153+
if (! $this->formatHasTime()) {
154+
return $this->parseSavedToCarbon($value)->format('Y-m-d');
155+
}
156+
153157
return $this->parseSaved($value)->toIso8601ZuluString('millisecond');
154158
}
155159

@@ -165,6 +169,13 @@ private function preProcessRange($value)
165169
if (! is_array($value)) {
166170
$carbon = $this->parseSavedToCarbon($value);
167171

172+
if (! $this->formatHasTime()) {
173+
return [
174+
'start' => $carbon->copy()->format('Y-m-d'),
175+
'end' => $carbon->copy()->format('Y-m-d'),
176+
];
177+
}
178+
168179
return [
169180
'start' => $carbon->copy()->startOfDay()->utc()->toIso8601ZuluString('millisecond'),
170181
'end' => $carbon->copy()->endOfDay()->utc()->toIso8601ZuluString('millisecond'),
@@ -222,6 +233,12 @@ private function processRange($data)
222233

223234
private function processDateTime($value)
224235
{
236+
if (! $this->formatHasTime()) {
237+
$date = Carbon::parse($value, config('app.timezone'));
238+
239+
return $this->formatAndCast($date, $this->saveFormat());
240+
}
241+
225242
$date = Carbon::parse($value, 'UTC');
226243

227244
return $this->formatAndCast($date, $this->saveFormat());
@@ -246,8 +263,8 @@ public function preProcessIndex($value)
246263
}
247264

248265
return [
249-
'start' => $this->parseSaved($value['start'])->toIso8601ZuluString('millisecond'),
250-
'end' => $this->parseSaved($value['end'])->toIso8601ZuluString('millisecond'),
266+
'start' => $this->preProcessIndexDate($value['start']),
267+
'end' => $this->preProcessIndexDate($value['end']),
251268
...$common,
252269
];
253270
}
@@ -258,11 +275,20 @@ public function preProcessIndex($value)
258275
}
259276

260277
return [
261-
'date' => $this->parseSaved($value)->toIso8601ZuluString('millisecond'),
278+
'date' => $this->preProcessIndexDate($value),
262279
...$common,
263280
];
264281
}
265282

283+
private function preProcessIndexDate($value)
284+
{
285+
if (! $this->formatHasTime()) {
286+
return $this->parseSavedToCarbon($value)->format('Y-m-d');
287+
}
288+
289+
return $this->parseSaved($value)->toIso8601ZuluString('millisecond');
290+
}
291+
266292
private function saveFormat()
267293
{
268294
return $this->config('format', $this->defaultFormat());
@@ -362,6 +388,16 @@ private function parseSavedToCarbon($value): Carbon
362388
}
363389
}
364390

391+
public function formatHasTime(): bool
392+
{
393+
return DateFormat::containsTime($this->saveFormat());
394+
}
395+
396+
public function preload()
397+
{
398+
return ['formatHasTime' => $this->formatHasTime()];
399+
}
400+
365401
public function timeEnabled()
366402
{
367403
return $this->config('time_enabled');

src/Rules/DateFieldtype.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,9 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
8888

8989
private function validDateFormat($value)
9090
{
91-
$format = 'Y-m-d\TH:i:s.v\Z';
91+
$format = $this->fieldtype->formatHasTime()
92+
? 'Y-m-d\TH:i:s.v\Z'
93+
: 'Y-m-d';
9294

9395
$date = DateTime::createFromFormat('!'.$format, $value);
9496

tests/Fieldtypes/DateTest.php

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,36 @@ public static function processProvider()
214214
['start' => '2012-08-29T00:00:00Z', 'end' => '2013-09-27T23:59:00Z'],
215215
['start' => '2012-08-28 20:00', 'end' => '2013-09-27 19:59'],
216216
],
217+
'date-only format' => [
218+
'UTC',
219+
['format' => 'Y-m-d'],
220+
'2012-08-29',
221+
'2012-08-29',
222+
],
223+
'date-only format in a different timezone' => [
224+
'America/New_York',
225+
['format' => 'Y-m-d'],
226+
'2012-08-29',
227+
'2012-08-29',
228+
],
229+
'date-only custom format' => [
230+
'UTC',
231+
['format' => 'Y--m--d'],
232+
'2012-08-29',
233+
'2012--08--29',
234+
],
235+
'date-only format range' => [
236+
'UTC',
237+
['mode' => 'range', 'format' => 'Y-m-d'],
238+
['start' => '2012-08-29', 'end' => '2013-09-27'],
239+
['start' => '2012-08-29', 'end' => '2013-09-27'],
240+
],
241+
'date-only format range in a different timezone' => [
242+
'America/New_York',
243+
['mode' => 'range', 'format' => 'Y-m-d'],
244+
['start' => '2012-08-29', 'end' => '2013-09-27'],
245+
['start' => '2012-08-29', 'end' => '2013-09-27'],
246+
],
217247
];
218248
}
219249

@@ -294,6 +324,30 @@ public static function preProcessProvider()
294324
'2012-08-29 13:43',
295325
'2012-08-29T17:43:00.000Z',
296326
],
327+
'date-only format' => [
328+
'UTC',
329+
['format' => 'Y-m-d'],
330+
'2012-08-29',
331+
'2012-08-29',
332+
],
333+
'date-only format in a different timezone' => [
334+
'America/New_York',
335+
['format' => 'Y-m-d'],
336+
'2012-08-29',
337+
'2012-08-29',
338+
],
339+
'date-only format in a positive offset timezone' => [
340+
'Australia/Sydney',
341+
['format' => 'Y-m-d'],
342+
'2012-08-29',
343+
'2012-08-29',
344+
],
345+
'date-only custom format' => [
346+
'America/New_York',
347+
['format' => 'Y--m--d'],
348+
'2012--08--29',
349+
'2012-08-29',
350+
],
297351
'null range' => [
298352
'UTC',
299353
['mode' => 'range'],
@@ -318,6 +372,18 @@ public static function preProcessProvider()
318372
['start' => '2012-08-29 00:00', 'end' => '2013-09-27 23:59'],
319373
['start' => '2012-08-29T04:00:00.000Z', 'end' => '2013-09-28T03:59:00.000Z'],
320374
],
375+
'date-only format range' => [
376+
'UTC',
377+
['mode' => 'range', 'format' => 'Y-m-d'],
378+
['start' => '2012-08-29', 'end' => '2013-09-27'],
379+
['start' => '2012-08-29', 'end' => '2013-09-27'],
380+
],
381+
'date-only format range in a different timezone' => [
382+
'America/New_York',
383+
['mode' => 'range', 'format' => 'Y-m-d'],
384+
['start' => '2012-08-29', 'end' => '2013-09-27'],
385+
['start' => '2012-08-29', 'end' => '2013-09-27'],
386+
],
321387
'range where single date has been provided' => [
322388
'UTC',
323389
// e.g. If it was once a non-range field.
@@ -326,11 +392,11 @@ public static function preProcessProvider()
326392
'2012-08-29',
327393
['start' => '2012-08-29T00:00:00.000Z', 'end' => '2012-08-29T23:59:59.999Z'],
328394
],
329-
'range where single date has been provided with custom format' => [
395+
'range where single date has been provided with date-only custom format' => [
330396
'UTC',
331397
['mode' => 'range', 'format' => 'Y--m--d'],
332398
'2012--08--29',
333-
['start' => '2012-08-29T00:00:00.000Z', 'end' => '2012-08-29T23:59:59.999Z'],
399+
['start' => '2012-08-29', 'end' => '2012-08-29'],
334400
],
335401
'date where range has been provided' => [
336402
'UTC',
@@ -459,6 +525,30 @@ public static function preProcessIndexProvider()
459525
['start' => '2012-08-29 00:00', 'end' => '2013-09-27 00:00'],
460526
['start' => '2012-08-29T00:00:00.000Z', 'end' => '2013-09-27T00:00:00.000Z', 'mode' => 'range', 'time_enabled' => true],
461527
],
528+
'date-only format' => [
529+
'UTC',
530+
['format' => 'Y-m-d'],
531+
'2012-08-29',
532+
['date' => '2012-08-29', 'mode' => 'single', 'time_enabled' => false],
533+
],
534+
'date-only format in a different timezone' => [
535+
'America/New_York',
536+
['format' => 'Y-m-d'],
537+
'2012-08-29',
538+
['date' => '2012-08-29', 'mode' => 'single', 'time_enabled' => false],
539+
],
540+
'date-only format range' => [
541+
'UTC',
542+
['mode' => 'range', 'format' => 'Y-m-d'],
543+
['start' => '2012-08-29', 'end' => '2013-09-27'],
544+
['start' => '2012-08-29', 'end' => '2013-09-27', 'mode' => 'range', 'time_enabled' => false],
545+
],
546+
'date-only format range in a different timezone' => [
547+
'America/New_York',
548+
['mode' => 'range', 'format' => 'Y-m-d'],
549+
['start' => '2012-08-29', 'end' => '2013-09-27'],
550+
['start' => '2012-08-29', 'end' => '2013-09-27', 'mode' => 'range', 'time_enabled' => false],
551+
],
462552
];
463553
}
464554

@@ -603,6 +693,16 @@ public static function validationProvider()
603693
'2024-01-29',
604694
['Not a valid date.'],
605695
],
696+
'valid date-only format' => [
697+
['format' => 'Y-m-d'],
698+
'2024-01-29',
699+
[],
700+
],
701+
'invalid date-only format' => [
702+
['format' => 'Y-m-d'],
703+
'marchtember oneteenth',
704+
['Not a valid date.'],
705+
],
606706
'ridiculous invalid date format' => [
607707
[],
608708
'marchtember oneteenth',

0 commit comments

Comments
 (0)